diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4dd0be89..7257961f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,12 +1,12 @@ { - "name": "scribe-plugin", + "name": "grimoire-plugin", "owner": { "name": "Bryan Van Deusen" }, - "description": "Scribe ships its own Claude Code plugin from this repo, versioned in lockstep with the app + the /api/plugin/context contract.", + "description": "Grimoire ships its own Claude Code plugin from this repo, versioned in lockstep with the app + the /api/plugin/context contract.", "plugins": [ { - "name": "scribe", + "name": "grimoire", "source": "./plugin", - "description": "Scribe second brain: MCP tools + session-start push channel + universal process-skills." + "description": "Grimoire second brain: MCP tools + session-start push channel + universal process-skills." } ] } diff --git a/.env.example b/.env.example index f62c5394..897c1f55 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ +# An install created before the rename from Scribe keeps its database by +# setting these, and PGDATA_VOLUME, to the names it was created with. POSTGRES_USER=fabled POSTGRES_PASSWORD=fabled -POSTGRES_DB=scribe +POSTGRES_DB=grimoire +# PGDATA_VOLUME=grimoire_pgdata SECRET_KEY=dev-secret-change-me diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index d86579ba..697a5621 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -4,27 +4,15 @@ # Push to main: typecheck + lint + test + build :latest + : # Tag v* (release): typecheck + lint + test + build :latest + : + : # -# Both dev and main are gated AND built. dev pushes move :dev; main pushes move -# :latest — main IS the production line, so :latest tracks main's tip and there -# is no separate :main tag. Every push also gets an immutable : (the -# rollback point). A v* release tag additionally publishes the dated :; -# since main already moved :latest, the release tag's distinct job is that -# : marker (it refreshes :latest too, harmlessly). +# main is the production line, so :latest tracks main's tip. Every push also +# gets an immutable :, the rollback point. A release (a v* tag created on +# main in the Forgejo UI) adds the dated :. # -# Successive pushes to the SAME ref supersede each other (see concurrency -# below), so rapid pushes don't stack identical work; dev and main runs are -# independent refs and never cancel one another. +# A newer push to the same ref cancels the older run; dev and main never cancel +# each other. Pull requests don't trigger runs: branch pushes already gate. # -# To cut a release: -# Create a release via the Forgejo UI on main with a v* tag name. -# The tag push triggers this workflow; build job pushes :latest + :. -# -# PRs aren't triggered on purpose — this is a solo dev→main flow, so -# gating on branch push is already enough. -# -# NOTE on the `if:` guards below: Forgejo Actions does not consistently -# honor `on.push.branches` as a filter, so every job repeats the ref check -# explicitly — permitting dev, main, and v* tags, rejecting anything else. +# Forgejo Actions doesn't reliably honour `on.push.branches` as a filter, so +# every job repeats the ref check in its `if:`. # # Required secrets (repo → Settings → Secrets → Actions): # REGISTRY_USER — your Forgejo username @@ -40,31 +28,22 @@ on: - "frontend/**" - "tests/**" - "pyproject.toml" - # The lock now determines what gets installed, so a lock-only change has - # to trigger a run — otherwise a dependency bump lands untested. + # The lock decides what is installed, so a lock-only change must run CI. - "uv.lock" - "alembic/**" - "alembic.ini" - "Dockerfile" - # The plugin ships straight from this repo — installs fetch it via - # .claude-plugin/marketplace.json, NOT from the image. So a push here is - # the release, with no build step in between. Omitting these paths meant - # plugin changes triggered no workflow at all, which is how #2198's three - # broken hooks and then #2209's missing version bump both reached a live - # install. See the `plugin` job below. + # The plugin ships straight from git (via .claude-plugin/marketplace.json), + # not in the image, so a push here is its release. See the `plugin` job. - "plugin/**" - ".claude-plugin/**" - "scripts/check_plugin.py" - ".forgejo/workflows/ci.yml" - # Manual trigger from the Forgejo Actions UI. Useful when an image has - # been built but the deployment didn't pick it up, or when re-running - # against the same source produces different upstream behaviour - # (e.g. a transient HF download flake during the voice-bundle step). + # Manual run from the Forgejo Actions UI, e.g. to rebuild an image. workflow_dispatch: {} -# Cancel older runs on the same branch when a newer push lands. Tag runs -# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and -# are never cancelled, so a release build can't kill itself mid-flight. +# A newer push cancels the older run on the same branch. Tag runs are never +# cancelled, so a release build can't be cut off mid-flight. concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} @@ -76,7 +55,7 @@ permissions: env: REGISTRY: git.fabledsword.com - IMAGE: git.fabledsword.com/bvandeusen/fabledscribe + IMAGE: git.fabledsword.com/bvandeusen/fabledgrimoire jobs: typecheck: @@ -91,10 +70,7 @@ jobs: - name: Cache npm download cache uses: actions/cache@v4 - # Non-fatal: a transient cache-backend hiccup must NOT fail the whole - # typecheck job (it was skipping install + type check and reporting red - # on backend-only pushes — see issue task #828). On cache miss/error the - # job just installs without the cache. + # Non-fatal: a cache-backend error just means installing without it. continue-on-error: true with: path: ~/.npm @@ -110,8 +86,7 @@ jobs: working-directory: frontend # Guards the one part of this repo that ships to users without a build step. - # See scripts/check_plugin.py for what it checks and, as importantly, what it - # can't check yet (shellcheck is absent from ci-python). + # scripts/check_plugin.py says what it checks and what it can't. plugin: name: Plugin hooks if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') @@ -119,25 +94,14 @@ jobs: container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - # Bare `uses:`, no `with:` block. Adding one made this action fail to - # extract on the act_runner ("Cannot find module .../dist/index.js") while - # every bare checkout in the same run succeeded — see run 3027. Nothing - # here needs `fetch-depth: 0` anyway: the version check diffs two trees, - # and a tree diff needs both trees, not a common ancestor. A depth-1 fetch - # of main's tip is enough, and cheaper. + # Keep this a bare `uses:`: with a `with:` block the action fails to + # extract on act_runner ("Cannot find module .../dist/index.js"). The + # version check diffs two trees, so a depth-1 fetch of main is enough. - uses: actions/checkout@v6 - # Per-job, not in the image, per CI-runner's docs/process.md: "If only one - # project needs the dep, prefer that project installing it per-job in - # their workflow — at least until a second consumer arrives." Scribe is - # the only consumer today. Promotion into ci-python is filed as an issue - # on CI-runner rather than assumed here. - # - # jq WAS installed here too, because every hook exited at line 1 without - # it and the smoke test would have passed while exercising nothing. The - # hooks need no jq since #4107 — they use only what POSIX guarantees — so - # the smoke test now runs on a bare image, which is the condition it is - # actually meant to be asserting. + # Per-job rather than in the image: Grimoire is shellcheck's only consumer. + # Install nothing else here; the hooks use only POSIX tools, and the + # smoke test runs on a bare image to prove it. - name: Install shell tooling run: | apt-get update -qq @@ -163,28 +127,19 @@ jobs: steps: - uses: actions/checkout@v6 - # ruff is pre-installed in the ci-python image — no install - # step needed, lint runs in ~2s. + # ruff is pre-installed in the ci-python image. - name: Lint run: ruff check src/ scripts/ - # Design tokens: does the frontend's CSS agree with the stylesheet the - # design system generates? Fails only on an unresolvable var() reference — - # that count is at zero, so this is a ratchet rather than a backlog. The - # literal findings are printed, not gated; hundreds exist and a - # permanently-red job is one nobody reads. - # - # Stdlib only, no install, no network: the source of truth is theme.css, - # which is generated from the design system and committed. + # Checks the frontend's CSS against the committed, generated theme.css. + # Fails only on an unresolvable var() reference; hard-coded literals are + # reported, not gated. - name: Design token check run: python3 scripts/check_design_tokens.py --report-literals - # Dangling styles: an element whose classes have only modifier rules and - # no base — a deleted CSS rule that left its `:hover` behind. Two shipped - # this way (a link rendering as raw browser blue, a flex row whose parent - # was gone so every child stacked). Neither is visible to vue-tsc; a dead - # style typechecks perfectly. Reported, not gated — a bare wrapper is - # legitimate, so the signal is the count growing. + # Reports elements whose classes have modifier rules but no base rule, + # which vue-tsc can't see. Not gated: a bare wrapper is legitimate, so + # the signal is the count growing. - name: Dangling style check run: python3 scripts/check_dangling_styles.py @@ -201,94 +156,66 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/uv - # Keyed on the LOCK, not pyproject: the lock is what determines the - # installed set now, and a pyproject edit that doesn't change - # resolution shouldn't throw the cache away. + # Keyed on the lock, which decides the installed set. key: uv-${{ hashFiles('uv.lock') }} restore-keys: uv- - # Installs exactly what uv.lock pins, and resolves nothing itself. - # - # This replaced `uv pip install -e ".[dev]"`, which resolved from the - # pyproject constraints and ignored the lock entirely. Every dependency - # floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main` - # red with no repo change (issue #2194). Green CI has to mean "these exact - # versions passed", or it isn't evidence of anything. - # - # `--locked` also FAILS when uv.lock is stale against pyproject, so a - # dependency edit has to go through a deliberate `uv lock` — it can't - # arrive on its own. That check earned its place immediately: it caught - # that the lock had been missing `pgvector` entirely (added to pyproject, - # never re-locked), which the old install path had been silently papering - # over by resolving from pyproject instead. + # Installs exactly what uv.lock pins, so green means these exact versions + # passed. `--locked` fails if the lock is stale against pyproject, so a + # dependency change needs a deliberate `uv lock`. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv run: uv sync --locked --extra dev - # An "Install jq for hook execution tests" step stood here. The hook - # EXECUTION tests run the real bash hook, which exited silently without - # jq and skipped rather than failed, so the step existed to stop them - # being silently unverified. Since #4107 the hooks need no jq, so the - # step is gone and those tests run against a bare image — and the point - # of removing a dependency is lost if CI keeps installing it anyway. + # The hook execution tests run on a bare image (no jq), like a user's + # machine might be. - name: Run tests # Integration tests (real Postgres) run in the `integration` job below. run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration" - # Real-Postgres lane (family rule 6). Exercises the async SQLAlchemy connection - # path the unit stubs can't reach — the un-awaited execution_options regression - # that made every VACUUM report 0/6 lived here. Like `test`, it runs for - # visibility and does NOT gate the build. + # Real Postgres, built by the real migrations: covers the async SQLAlchemy + # paths unit stubs can't reach. # - # Job key stays separator-free ("integration"): act_runner derives the service- - # container name from the (truncated) job display name and the discovery step - # filters `docker ps` by it. Service hostnames aren't routable on this runner, - # so the step resolves the Postgres container's bridge IP. No `name:` on purpose. + # Service hostnames aren't routable on this runner, so the step finds the + # Postgres container's bridge IP. The job has no `name:` and a separator-free + # key because act_runner names service containers after it. integration: if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 env: - # Config + the module engine read these at import time. DATABASE_URL itself - # is built from the discovered service IP in the run step. + # Read at import time. DATABASE_URL is built from the discovered IP below. SECRET_KEY: ci_integration_placeholder services: postgres: - # pgvector image so `alembic upgrade head` can run migration 0067 - # (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image. + # Same image as the shipped compose files: Postgres 17 with pgvector. image: pgvector/pgvector:pg17 env: - POSTGRES_USER: scribe + POSTGRES_USER: grimoire POSTGRES_PASSWORD: ci_integration - POSTGRES_DB: scribe_test + POSTGRES_DB: grimoire_test options: >- - --health-cmd "pg_isready -U scribe" + --health-cmd "pg_isready -U grimoire" --health-interval 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v6 - # Same locked install as the unit lane — the two must agree on versions, - # or "unit green, integration red" stops being a signal about the code. + # Same locked install as the unit lane, so both test the same versions. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv run: uv sync --locked --extra dev - # Standing answers to the checks carried by rules 81 and 79 — two facts - # about THIS runner that conditional rules assert as fact, and that - # otherwise need a throwaway job to confirm (#3237). Printing them on - # every integration run makes the next rulebook sweep a log read. - # Rule 80's evidence is the container listing the next step already - # prints. Every command is guarded: a diagnostic that can break the lane - # it observes is worse than no diagnostic. - - name: Runner facts (rules 79 and 81) + # Prints two facts about the runner that the steps below depend on. + # Every command is guarded so the diagnostic can't fail the lane. + - name: Runner facts (shell, service hostnames) run: | - echo "--- rule 81: which shell runs a run: step ---" + echo "--- which shell runs a run: step ---" readlink -f /bin/sh || echo "/bin/sh: not a symlink" ps -p $$ -o comm= || true - echo "--- rule 79: is a service reachable by its hostname yet? ---" + echo "--- is a service reachable by its hostname? ---" getent hosts postgres \ || echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required" - name: Integration suite (resolve service IP, migrate, test) @@ -296,12 +223,10 @@ jobs: set -eux echo "=== container landscape (diagnostic for the name filter) ===" docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' - # Only THIS job's services. The runner's docker daemon is shared, so another - # repo's job can be running beside this one, and a job-name filter matches its - # containers too (Steward run 8358 and Inkwell run 8653 each took another repo's - # Postgres). act names every container of a task GITEA-ACTIONS-TASK--...: read - # from our own container (its id is in the /etc/hostname bind mount) and - # require exactly one match. + # Only this job's services: the docker daemon is shared, so a job-name + # filter can match another repo's Postgres. act names a task's containers + # GITEA-ACTIONS-TASK--...; read from our own container (its id is in + # the /etc/hostname bind mount) and require exactly one match. SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true) SELF=${SELF:-$(hostname)} TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-') @@ -310,10 +235,8 @@ jobs: test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1 PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") test -n "$PG_IP" - export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test" - # Wait for Postgres to accept connections. The run: shell is dash - # (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by - # the step above) — no bash /dev/tcp, so use Python. + export DATABASE_URL="postgresql+asyncpg://grimoire:ci_integration@${PG_IP}:5432/grimoire_test" + # Wait for Postgres. The run: shell is dash (no bash /dev/tcp), so use Python. /opt/venv/bin/python - "$PG_IP" <<'PY' import socket, sys, time for _ in range(30): @@ -332,21 +255,12 @@ jobs: build: name: Build & push image - # `plugin` is deliberately NOT in needs. The plugin isn't in the image — - # installs fetch it from git — so gating the server image on a hook lint - # would couple two things that don't ship together, and blocking the build - # wouldn't un-publish a bad hook anyway: the push already did that. A failed - # `plugin` job still turns the whole run red, which is the signal that - # matters. - # - # `integration` IS in needs (rule 177, #4958). It was added after this - # gate and left out of it, so on 2026-10-05 main run 8212 published - # :latest with integration red. The image carries the migrations and the - # queries that lane runs against real Postgres; a red integration run is - # a red image. + # `plugin` is not in needs: the plugin ships from git, not in the image, so + # holding the image back wouldn't stop a bad hook. A red `plugin` job still + # turns the run red. `integration` is: the image carries the migrations and + # queries that lane tests. needs: [typecheck, lint, test, integration] - # Build on dev, main, and v* tag pushes. dev → :dev, main → :latest, - # tag → :latest + :; every build also gets an immutable :. + # dev → :dev, main → :latest, tag → :latest + :; all get :. if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: @@ -357,53 +271,37 @@ jobs: steps: - uses: actions/checkout@v6 with: - # Rule 149 asks for this on any job deriving the version NAME. The - # name here comes from HEAD's commit TIME, which a depth-1 clone - # already has — but the rule states it unconditionally because the - # failure it guards is silent (a too-low value, every lane green), - # and a later change to how the name is derived would inherit the - # landmine rather than the guard. + # Full history for the version derivation. Today it needs only HEAD's + # commit time, but a shallow clone fails silently (a too-low value) if + # the derivation ever reads further back. fetch-depth: 0 - name: Generate image tags and version id: tags - # POSIX `case` instead of bash `[[ ]]` because act_runner invokes - # `sh -e` (dash on the ci-python:3.14 image, which has no bash on - # the default PATH for /bin/sh). Previous `[[ ]]` form failed - # silently — only the SHA tag got appended, so :dev / :latest - # never updated in the registry and the deployed stack kept - # pulling stale images. Verified via `[[: not found` lines in - # the runner log on commit 2a374d9. + # POSIX `case`, not bash `[[ ]]`: the run: shell is dash, where `[[` + # fails silently and only the : tag would be pushed. run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" - # THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31 - # BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the - # image self-reported {"version":"main"}, a channel name where a - # build identifier belongs. That cost a debugging session: with the - # deploy misbehaving, nothing on the running instance could say - # which commit was serving it. + # Three values kept separate; the Dockerfile describes each. - # 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes - # since 2020-01-01. Never a commit count (not monotonic across - # branches) and never commit time (goes DOWN when an older - # commit is rebuilt). + # 1. Ordering key: build time in minutes since 2020-01-01, so it only + # goes up. Commit time would go down when an older commit is + # rebuilt; a commit count isn't monotonic across branches. BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 )) - # 2. NAME — COMMIT time, so the same source reports the same string - # on every lane and the channel is the only thing that differs. + # 2. Name: commit time, so the same source reads the same on every lane. COMMIT_TS=$(git log --format=%ct -1 HEAD) BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M) - # 3. CHANNEL — its own value. Never a suffix, never a segment. + # 3. Channel: its own value, never part of the name. CHANNEL="dev" case "${{ github.ref }}" in refs/heads/dev) TAGS="$TAGS,${{ env.IMAGE }}:dev" ;; refs/heads/main) - # main IS the production line: publish :latest (plus the : - # set above). No separate :main tag. + # main is the production line, so it publishes :latest. TAGS="$TAGS,${{ env.IMAGE }}:latest" CHANNEL="stable" ;; @@ -417,16 +315,10 @@ jobs: echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT echo "channel=$CHANNEL" >> $GITHUB_OUTPUT - - name: Free disk space - # Self-hosted runner housekeeping. Two-step cleanup: - # 1. Prune dangling containers/images globally (stops the runner - # from accumulating cruft from past failed builds). - # 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip - # mount cache survives but old intermediate layers don't - # accumulate indefinitely. - run: | - docker system prune -af || true - docker builder prune --keep-storage 5g -f || true + # No disk-space prune step: the runner's Docker daemon is shared, and + # `docker system prune -af` here deleted images other repos' jobs had + # just pulled and were about to start (CI-runner #16). The runner host + # prunes nightly instead. - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -445,19 +337,13 @@ jobs: push: true provenance: false tags: ${{ steps.tags.outputs.value }} - # All three, plus the commit — rule 145: the registry's identity for - # a build (:) and the artifact's identity for itself must - # agree, and they can only be checked against each other if the - # artifact says which commit it is. + # The commit lets the image's self-report be checked against :. build-args: | BUILD_VERSION=${{ steps.tags.outputs.build_name }} BUILD_KEY=${{ steps.tags.outputs.build_key }} BUILD_CHANNEL=${{ steps.tags.outputs.channel }} BUILD_COMMIT=${{ github.sha }} - # Registry-backed layer cache. Pull from :cache to prime - # BuildKit, push updated layers back to :cache so the next - # build starts warm even if the runner's local cache was - # pruned. `mode=max` exports all intermediate layers, not - # just the final image, which is what gives the ~80% speedup. + # Layer cache kept in the registry, so a build starts warm even after + # the runner prunes. `mode=max` caches intermediate layers too. cache-from: type=registry,ref=${{ env.IMAGE }}:cache cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true diff --git a/Dockerfile b/Dockerfile index c94d3854..d53ffedd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,8 @@ RUN npm run build FROM python:3.14-slim AS runtime WORKDIR /app -# Installed from uv.lock, exactly like CI (issue #2194). This used to be -# `COPY pyproject.toml .` + `pip install .`, which never even copied the lock: -# the shipped image resolved its own dependency set, so CI could be green on one -# set of versions while the published image ran another. On 2026-07-28 that -# class of drift turned `main` red when mcp 2.0.0 shipped mid-session. +# Installed from uv.lock, exactly like CI, so the image runs the dependency +# versions CI tested. RUN --mount=type=cache,target=/root/.cache/pip \ pip install --no-cache-dir uv @@ -34,28 +31,22 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # alembic and hypercorn in CMD have to be found there. ENV PATH="/app/.venv/bin:$PATH" -COPY --from=build-frontend /build/dist/ src/scribe/static/ +COPY --from=build-frontend /build/dist/ src/grimoire/static/ COPY alembic.ini . COPY alembic/ alembic/ # Ensure Python finds the source tree (where static files live) before site-packages ENV PYTHONPATH=/app/src -# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit. -# -# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same -# string on every lane for the same source, so it answers "is this the same -# code?" rather than "which lane built it?". -# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) — -# the only value anything may compare to decide what is newer. -# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name. -# BUILD_COMMIT lets the artifact's self-report be checked against the : -# it was published under (rule 145). -# -# Each defaults to empty rather than to a placeholder, EXCEPT the name: a -# local build genuinely has no ordering key or channel, and the endpoint says -# so by omitting them. Inventing values would make a local image claim a -# position in an update order it is not part of. +# Three values kept separate, plus the commit: +# BUILD_VERSION is the name (YYYY.MM.DD.HHMM from commit time), the same on +# every lane for the same source. +# BUILD_KEY is the ordering key (minutes since 2020-01-01 from build time), the +# only value compared to decide which build is newer. +# BUILD_CHANNEL is its own field, never part of the name. +# BUILD_COMMIT lets the image's self-report be checked against its : tag. +# A local build leaves the key and channel empty: it has no place in an update +# order, and /api/version omits them. ARG BUILD_VERSION=dev ARG BUILD_KEY= ARG BUILD_CHANNEL= @@ -66,4 +57,4 @@ ENV APP_CHANNEL=$BUILD_CHANNEL ENV APP_COMMIT=$BUILD_COMMIT EXPOSE 5000 -CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] +CMD ["sh", "-c", "alembic upgrade head && hypercorn 'grimoire.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] diff --git a/Makefile b/Makefile index 231f991b..cb7476ef 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ health: migrate: docker compose exec app alembic upgrade head -# --- Local dev (requires: pip install -e ".[dev]" and cd frontend && npm ci) --- +# --- Local dev (requires: uv sync --extra dev and cd frontend && npm ci) --- lint: ruff check src/ @@ -39,9 +39,8 @@ check: lint typecheck test # --- Plugin --- -# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE -# committing. The plugin ships straight from git with no build step, so its -# version is minted here rather than stamped by CI; the lane fails if you -# forget, but this is what makes remembering cheap. +# Run after changing plugin/ or .claude-plugin/, before committing: the plugin +# ships from git with no build step, so its version is minted here. CI fails if +# the version wasn't bumped. mint-plugin: python3 scripts/mint_plugin_version.py diff --git a/README.md b/README.md index efa16218..ae5c924e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Fabled Scribe +# Fabled Grimoire A self-hosted work system-of-record for software projects, built to be driven by Claude Code. Notes, tasks, issues, projects, milestones, rules, and stored processes — reachable from Claude via a built-in MCP endpoint and a bundled Claude Code plugin, with a clean web UI for humans. No in-app LLM; Claude is the sole assistant. ## Features -Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (with an inception step that decides what each project inherits), and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly. +Notes, tasks and issues; projects with milestones as plans; rules that reach Claude when its work matches them or at a moment of work; lessons, snippets of prior art and a ledger of the code they account for; design systems; ideas shared across projects on the same platform; and stored processes. Semantic search feeds both Claude's recall and the plugin's context injection. A knowledge graph, sharing with users and groups, and scheduled backups round it out. See [Features](docs/features.md). ## Quick Start @@ -19,7 +19,7 @@ export SECRET_KEY=your-random-secret-here docker compose -f docker-compose.quickstart.yml up -d ``` -Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → API Keys** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md). +Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → MCP Access** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md). > **Development:** To build from source, see [Development](docs/development.md). @@ -31,8 +31,10 @@ Open `http://localhost:5000`. The first user to register becomes admin. To conne | [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security | | [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts | | [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process | -| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide | +| [API Keys & MCP](docs/api-keys-and-mcp.md) | API keys and installing the Claude Code plugin | | [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers | +| [Backups](docs/backups.md) | Scheduled backups to a folder or S3, and restoring from one | +| [Design system](docs/design-system.md) | The FabledSword house style and how Grimoire applies it | | [API Reference](docs/api-reference.md) | All REST API endpoints | ## License diff --git a/alembic/env.py b/alembic/env.py index a7af4f88..e899d3df 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -4,8 +4,9 @@ from logging.config import fileConfig from alembic import context from sqlalchemy.ext.asyncio import create_async_engine -from scribe.config import Config -from scribe.models import Base +from grimoire.config import Config +from grimoire.models import Base +from grimoire.services.db_ready import wait_for_database config = context.config if config.config_file_name is not None: @@ -32,6 +33,7 @@ def do_run_migrations(connection): async def run_async_migrations() -> None: + await wait_for_database(Config.DATABASE_URL) engine = create_async_engine(Config.DATABASE_URL) async with engine.connect() as connection: await connection.run_sync(do_run_migrations) diff --git a/alembic/versions/0043_event_duration_replaces_end_dt.py b/alembic/versions/0043_event_duration_replaces_end_dt.py index 3fac32ad..b84d7210 100644 --- a/alembic/versions/0043_event_duration_replaces_end_dt.py +++ b/alembic/versions/0043_event_duration_replaces_end_dt.py @@ -1,13 +1,11 @@ -"""replace events.end_dt with duration_minutes (Fable #160) +"""replace events.end_dt with duration_minutes Revision ID: 0043 Revises: 0042 Create Date: 2026-04-29 -Structural fix for the "end before start" bug class observed on prod -2026-04-29: an event landed with end_dt 32 days before start_dt due -to a tool-call mishap, then disappeared from upcoming-list filters. -Storing duration instead of end_dt makes the invalid state +Structural fix for the "end before start" bug class: an event whose end_dt +precedes its start_dt disappears from upcoming-list filters. Storing duration instead of end_dt makes the invalid state inexpressible at the schema level (duration_minutes >= 0). Backfill rules: diff --git a/alembic/versions/0044_add_note_description_and_consolidated_at.py b/alembic/versions/0044_add_note_description_and_consolidated_at.py index 2fe7957f..6f23942a 100644 --- a/alembic/versions/0044_add_note_description_and_consolidated_at.py +++ b/alembic/versions/0044_add_note_description_and_consolidated_at.py @@ -5,7 +5,7 @@ Revises: 0043 Create Date: 2026-05-13 Adds two columns to the ``notes`` table to support the task-as-durable-record -design (spec 2026-05-13): +design: - ``description``: user-stated goal / initial context. Meaningful when ``is_task=true``; left NULL on knowledge notes. diff --git a/alembic/versions/0048_conversation_last_curator_run.py b/alembic/versions/0048_conversation_last_curator_run.py index 302f57d0..1ee64ad2 100644 --- a/alembic/versions/0048_conversation_last_curator_run.py +++ b/alembic/versions/0048_conversation_last_curator_run.py @@ -4,7 +4,7 @@ Revision ID: 0048 Revises: 0047 Create Date: 2026-05-22 -Phase 2 of the conversation+curator architecture (Fable #172). The +Part of the conversation+curator architecture. The scheduler runs every 15 minutes and processes any journal conversation with messages newer than its `last_curator_run_at` timestamp. NULL means "never run; process all of today on first sweep." diff --git a/alembic/versions/0049_conversation_curator_summary.py b/alembic/versions/0049_conversation_curator_summary.py index 6e1cea00..4570e96d 100644 --- a/alembic/versions/0049_conversation_curator_summary.py +++ b/alembic/versions/0049_conversation_curator_summary.py @@ -4,7 +4,7 @@ Revision ID: 0049 Revises: 0048 Create Date: 2026-05-22 -Phase 3 of the conversation+curator architecture (Fable #172). The +Part of the conversation+curator architecture. The curator's final summary line (≤ 240 chars) is persisted here so the NEXT chat turn can include it in the system prompt. This is the feedback loop that closes the architecture — without it, the chat diff --git a/alembic/versions/0053_drop_chat_journal_push_curator_tables.py b/alembic/versions/0053_drop_chat_journal_push_curator_tables.py index e21823a4..2aee7f69 100644 --- a/alembic/versions/0053_drop_chat_journal_push_curator_tables.py +++ b/alembic/versions/0053_drop_chat_journal_push_curator_tables.py @@ -4,13 +4,11 @@ Revision ID: 0053 Revises: 0052 Create Date: 2026-05-27 -Phase 9 of the MCP-first pivot. The Python models for these tables were -deleted in Phase 8 (commit 91bafb6); this migration drops the orphan +Part of the MCP-first pivot. The Python models for these tables are gone; +this migration drops the orphan SQL tables and cleans up dead per-user setting rows. -Hard-cutover: existing rows in these tables are discarded. The -accompanying spec at docs/superpowers/specs/2026-05-26-mcp-first-pivot-design.md -explicitly accepted this loss. +Hard-cutover: existing rows in these tables are discarded, deliberately. """ from alembic import op diff --git a/alembic/versions/0054_drop_image_cache.py b/alembic/versions/0054_drop_image_cache.py index 187b4022..b9324ff8 100644 --- a/alembic/versions/0054_drop_image_cache.py +++ b/alembic/versions/0054_drop_image_cache.py @@ -4,7 +4,7 @@ Revision ID: 0054 Revises: 0053 Create Date: 2026-05-27 -The image cache was wired into the LLM image-search tool (removed in Phase 8). +The image cache was wired into the LLM image-search tool, which is gone. With no producer or consumer left, the table is dropped here. """ from alembic import op diff --git a/alembic/versions/0055_rulebook_tables.py b/alembic/versions/0055_rulebook_tables.py index 54f48467..68a499e5 100644 --- a/alembic/versions/0055_rulebook_tables.py +++ b/alembic/versions/0055_rulebook_tables.py @@ -6,8 +6,8 @@ Create Date: 2026-05-27 Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling to Project. Rules carry a structural (statement, why, how_to_apply) -triple. Projects subscribe to Rulebooks (many-to-many). See the design -doc at docs/superpowers/specs/2026-05-27-scribe-rulebook-design.md. +triple. Projects subscribe to Rulebooks (many-to-many; migration 0101 +later drops subscriptions). """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0066_milestone_body.py b/alembic/versions/0066_milestone_body.py index 5f3fb0f1..9f78469a 100644 --- a/alembic/versions/0066_milestone_body.py +++ b/alembic/versions/0066_milestone_body.py @@ -4,7 +4,7 @@ Revision ID: 0066 Revises: 0065 Create Date: 2026-06-14 -T3 of plan #819. The milestone becomes the plan container: its `body` holds +The milestone becomes the plan container: its `body` holds the design/intent/purpose (markdown), `description` stays the one-liner, and individual steps live as first-class child tasks (milestone_id) instead of checkboxes crammed into a kind=plan task body. start_planning is reworked to diff --git a/alembic/versions/0074_design_prose.py b/alembic/versions/0074_design_prose.py index b48971ce..43e7246b 100644 --- a/alembic/versions/0074_design_prose.py +++ b/alembic/versions/0074_design_prose.py @@ -4,8 +4,7 @@ Revision ID: 0074 Revises: 0073 Create Date: 2026-07-31 -The operator's shape for #254: prose doesn't live as one-offs, it lives centrally -on the system. Two fields rather than one per category: +Prose doesn't live as one-offs; it lives centrally on the system. Two fields rather than one per category: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. @@ -17,7 +16,7 @@ on the system. Two fields rather than one per category: Free-form rather than a column per category on purpose. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into -every install (rule #115), and the next install's design system would have three +every install, and the next install's design system would have three empty columns and nowhere to put what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. diff --git a/alembic/versions/0076_note_supersessions.py b/alembic/versions/0076_note_supersessions.py index 8fd6b86b..8db1e483 100644 --- a/alembic/versions/0076_note_supersessions.py +++ b/alembic/versions/0076_note_supersessions.py @@ -4,7 +4,7 @@ Revision ID: 0076 Revises: 0075 Create Date: 2026-08-07 -Step 1 of milestone #278. Structure only — nothing reads or writes the new +Structure only — nothing reads or writes the new table yet, and nothing behaves differently after this runs. ## What the table is for @@ -39,7 +39,7 @@ and not for notes, so the column looked like the notes-side half of that feature, modelled and abandoned. It is dropped rather than repurposed for supersession, and the distinction is -the point (#2483): consolidation folds several records into one survivor and +the point: consolidation folds several records into one survivor and destroys the originals. Merging two snippets is lossless — one helper, several call sites. Folding two dev-logs means writing a summary and losing what each actually said. Supersession is the opposite act: both records survive, and the diff --git a/alembic/versions/0077_chunked_embeddings.py b/alembic/versions/0077_chunked_embeddings.py index f7a8bd84..9ac3277d 100644 --- a/alembic/versions/0077_chunked_embeddings.py +++ b/alembic/versions/0077_chunked_embeddings.py @@ -1,4 +1,4 @@ -"""Chunked embeddings: one note_embeddings row per chunk (#280) +"""Chunked embeddings: one note_embeddings row per chunk Revision ID: 0077 Revises: 0076 diff --git a/alembic/versions/0078_forge_connections.py b/alembic/versions/0078_forge_connections.py index da9b7808..f5ec0b18 100644 --- a/alembic/versions/0078_forge_connections.py +++ b/alembic/versions/0078_forge_connections.py @@ -1,4 +1,4 @@ -"""Forge connections move to the user level (#2778) +"""Forge connections move to the user level Revision ID: 0078 Revises: 0077 @@ -12,7 +12,7 @@ keyring, with an optional per-project pin (projects.forge_connection_id). The data move carries the existing admin config into a connection row for the first admin user (host parsed from the base URL), then deletes the old -setting keys outright — no legacy dual-read (rule #22). The env-var channel +setting keys outright — no legacy dual-read. The env-var channel (FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it survives as an implicit keyring entry for admin users only. """ diff --git a/alembic/versions/0079_code_shapes_ledger.py b/alembic/versions/0079_code_shapes_ledger.py index e548bc2f..771f882b 100644 --- a/alembic/versions/0079_code_shapes_ledger.py +++ b/alembic/versions/0079_code_shapes_ledger.py @@ -1,13 +1,13 @@ -"""The shape ledger: code_shapes (#2787, milestone 294) +"""The shape ledger: code_shapes Revision ID: 0079 Revises: 0078 Create Date: 2026-08-19 -The accounting half of the pattern system (governing note 2786): the snippet +The accounting half of the pattern system: the snippet library records canon (small); this table accounts for EVERY shape the coverage extractor finds in a bound repo (total). Rows arrive `unclassified` -from the coverage sync (step 2) and gain judgments — canonical / instance / +from the coverage sync and gain judgments — canonical / instance / variant / exempt — from audits, hooks, and the mechanical proposer. Unclassified IS the todo list. """ diff --git a/alembic/versions/0080_code_shape_proposals.py b/alembic/versions/0080_code_shape_proposals.py index 7a6e9320..e93c2ee9 100644 --- a/alembic/versions/0080_code_shape_proposals.py +++ b/alembic/versions/0080_code_shape_proposals.py @@ -1,4 +1,4 @@ -"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294) +"""Shape fingerprints + the mechanical proposer's columns Revision ID: 0080 Revises: 0079 diff --git a/alembic/versions/0081_shape_history_and_divergence.py b/alembic/versions/0081_shape_history_and_divergence.py index 052f8836..429afc04 100644 --- a/alembic/versions/0081_shape_history_and_divergence.py +++ b/alembic/versions/0081_shape_history_and_divergence.py @@ -1,4 +1,4 @@ -"""Shape history, recheck, and the divergence flag (#2793, milestone 294) +"""Shape history, recheck, and the divergence flag Revision ID: 0081 Revises: 0080 diff --git a/alembic/versions/0082_repo_binding_ref.py b/alembic/versions/0082_repo_binding_ref.py index 30f49164..c31bab79 100644 --- a/alembic/versions/0082_repo_binding_ref.py +++ b/alembic/versions/0082_repo_binding_ref.py @@ -1,13 +1,13 @@ -"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294) +"""Per-binding ref — the branch a project's ledger follows Revision ID: 0082 Revises: 0081 Create Date: 2026-08-21 -A repo binding used to imply the repo's default branch; the shape ledger -therefore only saw work after a merge to main, while the operator's work -lands on dev (rule 1). `ref` names the branch the coverage refresh reads — -NULL keeps today's behaviour (the forge's default branch). +Without a ref, a repo binding implies the repo's default branch, so the shape +ledger only sees work after a merge — while a project may do its work on a +dev branch. `ref` names the branch the coverage refresh reads; NULL means the +forge's default branch. """ import sqlalchemy as sa from alembic import op diff --git a/alembic/versions/0083_code_shape_reason_code.py b/alembic/versions/0083_code_shape_reason_code.py index 6798ae70..19d62045 100644 --- a/alembic/versions/0083_code_shape_reason_code.py +++ b/alembic/versions/0083_code_shape_reason_code.py @@ -1,4 +1,4 @@ -"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294) +"""Exempt/variant reason codes — a small fixed catalogue beside the prose Revision ID: 0083 Revises: 0082 diff --git a/alembic/versions/0084_code_shape_uses.py b/alembic/versions/0084_code_shape_uses.py index 652a7f13..d601f4cb 100644 --- a/alembic/versions/0084_code_shape_uses.py +++ b/alembic/versions/0084_code_shape_uses.py @@ -1,4 +1,4 @@ -"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294) +"""code_shape_uses — consumption edges, separate from conformance Revision ID: 0084 Revises: 0083 diff --git a/alembic/versions/0085_project_inception.py b/alembic/versions/0085_project_inception.py index 40e776be..36a5f63c 100644 --- a/alembic/versions/0085_project_inception.py +++ b/alembic/versions/0085_project_inception.py @@ -1,4 +1,4 @@ -"""Project inception: the decision record + always-on rulebook exclusions (milestone 297) +"""Project inception: the decision record + always-on rulebook exclusions Revision ID: 0085 Revises: 0084 diff --git a/alembic/versions/0086_code_shape_consumers.py b/alembic/versions/0086_code_shape_consumers.py index d6c017d9..ac85a680 100644 --- a/alembic/versions/0086_code_shape_consumers.py +++ b/alembic/versions/0086_code_shape_consumers.py @@ -1,4 +1,4 @@ -"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917) +"""code_shape_consumers — the CSS consumer map Revision ID: 0086 Revises: 0085 diff --git a/alembic/versions/0087_canonical_systems.py b/alembic/versions/0087_canonical_systems.py index a765b80f..1a9fb709 100644 --- a/alembic/versions/0087_canonical_systems.py +++ b/alembic/versions/0087_canonical_systems.py @@ -1,12 +1,12 @@ """canonical_systems — the global area vocabulary, promoted from a constant -to a table (milestone 307 step 1, decision note 3026) +to a table Revision ID: 0087 Revises: 0086 Create Date: 2026-08-26 The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in -services/systems.py that milestone 297 seeds into a project at inception. A +services/systems.py that is seeded into a project at inception. A constant cannot be referenced: a rule that applies across projects has nothing to point at, because `systems.project_id` is NOT NULL and a family rule cannot be chained to one project's row. This makes the vocabulary a table so it can be @@ -35,7 +35,7 @@ depends_on = None # service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics # collapsed to "-"). Charters stay generic on purpose: a project refines its # own System's description, never this one. Nothing here names an app, a repo, -# a vendor or a house convention — the catalog ships to every install (rule 115). +# a vendor or a house convention — the catalog ships to every install. _SEED = ( ("CI & Release", "ci-and-release", "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."), diff --git a/alembic/versions/0088_rule_trigger_tier_tags_relations.py b/alembic/versions/0088_rule_trigger_tier_tags_relations.py index 502f147f..5644d746 100644 --- a/alembic/versions/0088_rule_trigger_tier_tags_relations.py +++ b/alembic/versions/0088_rule_trigger_tier_tags_relations.py @@ -1,5 +1,4 @@ -"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307 -step 3, decision note 3026) +"""rules gain a trigger, a tier, canon tags and typed edges Revision ID: 0088 Revises: 0087 @@ -31,8 +30,8 @@ down_revision = "0087" branch_labels = None depends_on = None -# Kept in one place so upgrade and the CHECK agree by construction (rule 36: -# a whitelisted value means DROP + ADD CONSTRAINT in the same migration — +# Kept in one place so upgrade and the CHECK agree by construction (a +# whitelisted value means DROP + ADD CONSTRAINT in the same migration — # there is no prior constraint here, so the pair is created together). _TIERS = ("always_on", "conditional") _RELATION_KINDS = ("co_surfaces", "overrides", "elaborates") diff --git a/alembic/versions/0089_rule_embeddings.py b/alembic/versions/0089_rule_embeddings.py index 3537c820..8cabb3f0 100644 --- a/alembic/versions/0089_rule_embeddings.py +++ b/alembic/versions/0089_rule_embeddings.py @@ -1,5 +1,4 @@ -"""rule_embeddings — rules become findable by meaning (milestone 307 step 4, -decision note 3026) +"""rule_embeddings — rules become findable by meaning Revision ID: 0089 Revises: 0088 diff --git a/alembic/versions/0090_rule_verification.py b/alembic/versions/0090_rule_verification.py index 0ba64a98..d148dbba 100644 --- a/alembic/versions/0090_rule_verification.py +++ b/alembic/versions/0090_rule_verification.py @@ -1,5 +1,4 @@ """a rule can carry its own check — verify_with, expires_when, verified_at -(milestone 312 step 1) Revision ID: 0090 Revises: 0089 @@ -11,10 +10,9 @@ know they did. A CONSTRAINT is a fact about someone else's software: a runner's shell, a bot's config, a tool that exists. Nobody is present when that goes false. -Milestone 307's rulebook audit found nine stale sites. Every one was a -constraint; not one norm had rotted. One of them had been telling every -session to skip database-backed tests for weeks while the integration lane -sat green in the workflow. +What goes stale in a rulebook is the constraints, not the norms — and a +stale constraint keeps instructing every session that loads it, long after +the world it described has moved. Three nullable columns, so a rule can say how to check itself: @@ -31,7 +29,7 @@ none of them. A null `verify_with` is not an omission — it is the honest marker of "this one is a decision, and there is nothing to go and check." That signal only works if the field stays empty wherever it belongs empty. -No CHECK constraint is involved, so rule 36 does not apply here. Nothing is +No CHECK constraint is involved. Nothing is backfilled: a migration cannot invent a check any more than 0088 could invent a trigger. """ diff --git a/alembic/versions/0091_task_kind_spike.py b/alembic/versions/0091_task_kind_spike.py index 5b03374c..82c6bfb6 100644 --- a/alembic/versions/0091_task_kind_spike.py +++ b/alembic/versions/0091_task_kind_spike.py @@ -1,5 +1,4 @@ """task_kind gains 'spike' — the investigation, not the change -(milestone 312 step 5) Revision ID: 0091 Revises: 0090 @@ -12,12 +11,12 @@ end of it. "Find out whether the runner can be given a bash shell" is not work, and filing it as work makes a finished investigation look like an abandoned change. -It is the record a failed check asks for. Milestone 312 gave rules a +It is the record a failed check asks for. Rules carry a `verify_with`; when one of those fails, the rule is wrong and the next move is often to go and find out what replaced it. `notes.arose_from_id` already exists (0065), so that constraint -> spike link needs no further schema. -Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the +`task_kind` is gated by a CHECK whitelist, so the value and the widened constraint land in the SAME migration — DROP then ADD, exactly as 0065 did when it introduced 'issue'. Adding the value and constraining it later leaves a window where the database accepts anything. diff --git a/alembic/versions/0092_note_verification.py b/alembic/versions/0092_note_verification.py index 52f73d1e..e98b5c81 100644 --- a/alembic/versions/0092_note_verification.py +++ b/alembic/versions/0092_note_verification.py @@ -1,5 +1,4 @@ """a note can carry its own check — verify_with, expires_when, verified_at -(milestone 317 step 1) Revision ID: 0092 Revises: 0091 @@ -32,8 +31,7 @@ Three nullable columns: WHICH ROWS THESE ARE FOR. `notes` is one table holding notes, tasks, snippets and processes, so these columns land on all of them. Only non-task, -non-snippet records are OFFERED them (milestone 317 decisions 1 and 2, gated -at the service in step 2): a task's decay is its status, and a snippet +non-snippet records are OFFERED them (gated at the service): a task's decay is its status, and a snippet already carries a richer, location-aware verdict in `data.verification`. The columns exist on the other rows and stay null there; a gate that lives in the schema would have meant a partial index or a CHECK across three columns @@ -44,7 +42,7 @@ value of the sweep is that its output is short. A null `verify_with` is not an omission; it is the honest marker of "this one is a decision, and there is nothing to go and check." -No CHECK constraint is involved, so rule 36 does not apply. Nothing is +No CHECK constraint is involved. Nothing is backfilled: a migration cannot invent a check. """ import sqlalchemy as sa @@ -69,9 +67,8 @@ def upgrade() -> None: # # Still a sequential scan's job at this size, and an index on # (verified_at) filtered to `verify_with IS NOT NULL` would be maintained - # on every note write to serve one operator-initiated query. If step 3's - # live acceptance measures otherwise, add it there against a real plan - # rather than guessing here. + # on every note write to serve one operator-initiated query. If a real + # plan says otherwise, add it then rather than guessing here. def downgrade() -> None: diff --git a/alembic/versions/0093_rule_versions.py b/alembic/versions/0093_rule_versions.py index 10401961..9f45c464 100644 --- a/alembic/versions/0093_rule_versions.py +++ b/alembic/versions/0093_rule_versions.py @@ -1,4 +1,4 @@ -"""rules gain an edit history — rule_versions (milestone 323 step 1) +"""rules gain an edit history — rule_versions Revision ID: 0093 Revises: 0092 @@ -7,12 +7,9 @@ Create Date: 2026-08-29 The sibling `note_versions` has had for a long time. A note's every meaningful edit is snapshotted, and the design-system note calls that history "the changelog". A RULE — which binds behaviour on every session that loads it — -had nothing: an edit destroyed what it used to say, with no record anywhere. - -Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement -had to be hand-copied into a task log to survive the edit (#3237), which is -not a process, it is a person remembering. The more consequential record had -the weaker protection. +had nothing: an edit destroyed what it used to say, with no record anywhere, +and keeping a superseded statement meant a person remembering to copy it +somewhere. The more consequential record had the weaker protection. Three things are deliberately NOT copied from note_versions, and each is a guard that exists there for a reason that does not hold here: @@ -31,7 +28,7 @@ guard that exists there for a reason that does not hold here: CASCADE: deleting a user must not erase the history of the rules they edited. The edit still happened and the rule still binds because of it. -No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a +No CHECK constraint is involved. Nothing is backfilled — a migration cannot invent the text a rule used to have, and inventing "the current text, as of now" would be worse than an empty history, because it would look like a record of an edit that never occurred. diff --git a/alembic/versions/0094_rule_usage_events.py b/alembic/versions/0094_rule_usage_events.py index 365650d6..028eca5d 100644 --- a/alembic/versions/0094_rule_usage_events.py +++ b/alembic/versions/0094_rule_usage_events.py @@ -1,4 +1,4 @@ -"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1) +"""add rule_usage_events — was a surfaced rule ever read? Revision ID: 0094 Revises: 0093 @@ -9,18 +9,15 @@ table to arrive after `rule_embeddings` and `rule_versions` — each one added because the rule side kept inheriting machinery built for notes and getting the weaker version of it. -WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval -surface in Scribe whose usefulness cannot be observed, and — not coincidentally -— the only one that has never declined to fire. Over 30 days it took 296 calls, -returned something on every one, and cleared its threshold 100% of the time, -while every other surface declines most of the time (#3311). That is either a -perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs` +WHAT IT MEASURES. A standing-rule arm that returns something on every call +and clears its threshold every time is either a perfectly tuned surface or a +bar it cannot fail to clear, and `retrieval_logs` cannot tell them apart: it records what the ranker scored, never whether the hint was any use. WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific fields and the aggregate readout is the same shape, which is the strongest case -for sharing that note #3163 admits. What decides against it is identity at +for sharing there is. What decides against it is identity at RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map` and drops what does not resolve. A rule id parked in that column would come back from a backup silently reattached to whatever note took that number — @@ -34,8 +31,8 @@ and dies with it; telemetry outlives the row it describes. Deleting a rule must not erase the evidence that it was surfaced forty times and opened never, since that evidence is exactly the case for having deleted it. -No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to -a column that is already gated; it does not require gating one that never was, +No CHECK on `event`, matching the note twin. DROP + ADD governs adding a value +to a column that is already gated; it does not require gating one that never was, and a two-member enum whose members are written by two functions in one module is not where that discipline earns its cost. diff --git a/alembic/versions/0095_retrieval_log_suppressed_count.py b/alembic/versions/0095_retrieval_log_suppressed_count.py index 87fe5aac..d578d6d8 100644 --- a/alembic/versions/0095_retrieval_log_suppressed_count.py +++ b/alembic/versions/0095_retrieval_log_suppressed_count.py @@ -1,4 +1,4 @@ -"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat (#3497) +"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat Revision ID: 0095 Revises: 0094 @@ -20,9 +20,8 @@ the dropped rows never come back and there is nothing to count. NULLABLE, AND THE NULL IS THE POINT. A surface that does not measure suppression stores NULL, not 0, and the readout renders it as "not measured" rather than "none". Defaulting to 0 would make an unmeasured surface look like -a perfectly clean one — the exact substitution of an artifact for a -measurement that #3311 made and that #3497 exists to correct. Doing it again, -in the migration that fixes it, would be its own small joke. +a perfectly clean one — the substitution of an artifact for a measurement +that this column exists to prevent. No backfill for the same reason: existing rows genuinely do not know, and saying so is the honest state. `retrieval_logs` is not restored from backup, diff --git a/alembic/versions/0096_retrieval_log_best_available_score.py b/alembic/versions/0096_retrieval_log_best_available_score.py index 4772572b..9cacd01e 100644 --- a/alembic/versions/0096_retrieval_log_best_available_score.py +++ b/alembic/versions/0096_retrieval_log_best_available_score.py @@ -1,4 +1,4 @@ -"""add retrieval_logs.best_available_score — the score the bar rejected (#3670) +"""add retrieval_logs.best_available_score — the score the bar rejected Revision ID: 0096 Revises: 0095 @@ -34,7 +34,7 @@ NULLABLE, AND UNBACKFILLED, for the reason 0095 spells out: a row written before this shipped genuinely does not know what its best rejected candidate scored, and saying so is the honest state. A 0.0 default would read as "the corpus had nothing remotely relevant" — an artifact standing in for a -measurement, which is the whole defect this milestone corrects. +measurement, which is the defect this column corrects. `retrieval_logs` is not restored from backup, so no importer changes. diff --git a/alembic/versions/0097_retrieval_log_best_available_id.py b/alembic/versions/0097_retrieval_log_best_available_id.py index 6edc8c67..994f8426 100644 --- a/alembic/versions/0097_retrieval_log_best_available_id.py +++ b/alembic/versions/0097_retrieval_log_best_available_id.py @@ -1,4 +1,4 @@ -"""add retrieval_logs.best_available_id — WHICH record the bar refused (#3807) +"""add retrieval_logs.best_available_id — WHICH record the bar refused Revision ID: 0097 Revises: 0096 diff --git a/alembic/versions/0098_rule_kind.py b/alembic/versions/0098_rule_kind.py index 35bb0aaa..8902a5ce 100644 --- a/alembic/versions/0098_rule_kind.py +++ b/alembic/versions/0098_rule_kind.py @@ -1,4 +1,4 @@ -"""rules gain a `kind` — a preference is a rule that does not bind (#3849) +"""rules gain a `kind` — a preference is a rule that does not bind Revision ID: 0098 Revises: 0097 @@ -37,7 +37,7 @@ install upgrades and every existing row keeps the force it had. That is the same reasoning 0088 used for `tier`, and it is the reason both are safe to apply without reading the data first. -The CHECK is created with the column (rule 36: there is no prior constraint, +The CHECK is created with the column (there is no prior constraint, so the pair is created together — a value added to it LATER does DROP + ADD in one migration). diff --git a/alembic/versions/0099_scrub_secrets_from_retrieval_logs.py b/alembic/versions/0099_scrub_secrets_from_retrieval_logs.py index d2c5f9f0..d47ca01a 100644 --- a/alembic/versions/0099_scrub_secrets_from_retrieval_logs.py +++ b/alembic/versions/0099_scrub_secrets_from_retrieval_logs.py @@ -7,7 +7,7 @@ Create Date: 2026-09-11 `pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule` against the code being written, so whatever was on the command line or in the buffer is what `record_retrieval` wrote into `retrieval_logs.query`. A command -that exported a token stored the token (#3925). +that exported a token stored the token. `services/retrieval_telemetry.scrub_secrets` closes that going forward — the value never reaches the column. It cannot reach BACKWARDS, and this does: it diff --git a/alembic/versions/0100_drop_always_on_tier.py b/alembic/versions/0100_drop_always_on_tier.py index a5242815..f3246fd8 100644 --- a/alembic/versions/0100_drop_always_on_tier.py +++ b/alembic/versions/0100_drop_always_on_tier.py @@ -4,14 +4,14 @@ Revision ID: 0100 Revises: 0099 Create Date: 2026-09-11 -Milestone 394. Every rule now reaches a session by retrieval — because +Every rule reaches a session by retrieval — because something it is about to do made the rule relevant — and the machinery that delivered rules unconditionally goes with it. WHAT GOES, AND WHERE IT CAME FROM - ``rules.tier`` and its ``ck_rules_tier`` CHECK (migration 0088). Dropping - the column takes the constraint with it. Rule 36 is about ADDING a value to + the column takes the constraint with it. DROP + ADD is about ADDING a value to a live whitelist, which needs DROP + ADD in the same migration; it does not speak to removing the column outright, and saying so here is cheaper than the next reader wondering whether it was forgotten. diff --git a/alembic/versions/0101_drop_rulebook_subscriptions.py b/alembic/versions/0101_drop_rulebook_subscriptions.py index f13ce3b5..88a7481c 100644 --- a/alembic/versions/0101_drop_rulebook_subscriptions.py +++ b/alembic/versions/0101_drop_rulebook_subscriptions.py @@ -4,12 +4,12 @@ Revision ID: 0101 Revises: 0100 Create Date: 2026-09-15 -Milestone 414. A rule lives in a rulebook topic, where it is GLOBAL, or on one -project, and retrieval reads that home directly (step 1). Subscriptions were -the last thing that pretended a rulebook reached some projects and not others, -and after milestone 394 they changed nothing a session received — only what a -project's rule LISTING showed. Operator, 2026-09-15: "we have global and -project scoped rules, we don't need the subscriptions now." +A rule lives in a rulebook topic, where it is GLOBAL, or on one project, and +retrieval reads that home directly. Subscriptions were the last thing that +pretended a rulebook reached some projects and not others, and with no +always-on tier they changed nothing a session received — only what a +project's rule LISTING showed. Global and project-scoped rules cover what +subscriptions were for. WHAT GOES @@ -19,8 +19,8 @@ WHAT GOES there is nothing to mute; a project that departs from a global rule writes a project rule with an ``overrides`` relation, which says why. - The ``subscribe_rulebooks`` key inside ``projects.inception.choices``, and - the ``exclude_always_on_rulebooks`` key milestone 394 left behind in the - same place. Both describe decisions that can no longer be made; a stored + the ``exclude_always_on_rulebooks`` key the retired always-on tier left + in the same place. Both describe decisions that can no longer be made; a stored record carrying them would be read back as a choice the product offers. IRREVERSIBLE, AND THE DOWNGRADE SAYS SO diff --git a/alembic/versions/0102_milestone_embeddings.py b/alembic/versions/0102_milestone_embeddings.py index 61281aa7..df757182 100644 --- a/alembic/versions/0102_milestone_embeddings.py +++ b/alembic/versions/0102_milestone_embeddings.py @@ -1,4 +1,4 @@ -"""milestone_embeddings — a plan becomes findable by meaning (milestone 415) +"""milestone_embeddings — a plan becomes findable by meaning Revision ID: 0102 Revises: 0101 @@ -9,8 +9,8 @@ lives in — could not be found at all. So "is there already a plan for this?" had no tool, and a project whose roadmap was written as milestones had every later plan opened as a new milestone beside the one that already described it. -The sibling of rule_embeddings (0089), for the reasons its model docstring and -note 3163 give. The vectors are DERIVED: nothing is backfilled here, the startup +The sibling of rule_embeddings (0089), for the reasons its model docstring +gives. The vectors are DERIVED: nothing is backfilled here, the startup backfill writes them. """ import sqlalchemy as sa diff --git a/alembic/versions/0103_retrieval_tuning_events.py b/alembic/versions/0103_retrieval_tuning_events.py index 7dc69ac3..68ff1651 100644 --- a/alembic/versions/0103_retrieval_tuning_events.py +++ b/alembic/versions/0103_retrieval_tuning_events.py @@ -1,16 +1,12 @@ -"""retrieval_tuning_events — why a floor is where it is (#4102) +"""retrieval_tuning_events — why a floor is where it is Revision ID: 0103 Revises: 0102 Create Date: 2026-09-17 -Milestone 416 stops shipping similarity thresholds as values somebody has to -defend, and hands the adjustment to the model that reads the surface's own -telemetry. The operator's decision: - - "the floor should be chosen and adjusted by the model using it… the user - should be able to touch it but the model should be the thing handling it 9 - times out of 10." +Similarity thresholds are not values somebody has to defend: the model that +reads the surface's own telemetry adjusts them, and the operator can still +touch them. The number itself already has a home — the generic settings table. What has no home is the ARGUMENT, and once the values move on their own the argument is the diff --git a/alembic/versions/0104_tuning_events_carry_their_calibration.py b/alembic/versions/0104_tuning_events_carry_their_calibration.py index 22117142..de4cb661 100644 --- a/alembic/versions/0104_tuning_events_carry_their_calibration.py +++ b/alembic/versions/0104_tuning_events_carry_their_calibration.py @@ -1,10 +1,10 @@ -"""retrieval_tuning_events carries the space each number was measured in (#4104) +"""retrieval_tuning_events carries the space each number was measured in Revision ID: 0104 Revises: 0103 Create Date: 2026-09-17 -Milestone 416 step 6. A retrieval floor is a cosine distance in ONE embedding +A retrieval floor is a cosine distance in ONE embedding model's geometry, computed over documents cut one particular way. Change the model and every score moves at once; change the chunker and the same record embeds different text. Either way a number chosen before the change is a @@ -16,7 +16,7 @@ merely unknown. the startup backfill re-embeds precisely what is stale instead of wiping the table. These two columns are that idea applied to the tuned numbers. -TWO COLUMNS, NOT ONE (rule 149). A reader has to be able to say WHICH half +TWO COLUMNS, NOT ONE. A reader has to be able to say WHICH half moved: a new embedding model and a re-cut document shape invalidate the same numbers for different reasons, and a fused `"@"` could only report that something changed. diff --git a/alembic/versions/0105_note_usage_events_carry_the_reading_project.py b/alembic/versions/0105_note_usage_events_carry_the_reading_project.py index 721fef3e..ce31536c 100644 --- a/alembic/versions/0105_note_usage_events_carry_the_reading_project.py +++ b/alembic/versions/0105_note_usage_events_carry_the_reading_project.py @@ -1,20 +1,19 @@ -"""note_usage_events records WHICH project the reader was in (#4196, #3735) +"""note_usage_events records WHICH project the reader was in Revision ID: 0105 Revises: 0104 Create Date: 2026-09-20 -Milestone 385. `RetrievalLog` has carried `project_id` from the start, so +`RetrievalLog` has carried `project_id` from the start, so "this record was SURFACED on project B" has always been answerable. The usage table never had one, so "this record was OPENED on project B" never was — and the two cannot be joined to recover it, because there is no session identity server-side (see the NoteUsageEvent docstring, which rules that out on purpose). -That gap sits exactly on the question milestone 385 exists to answer. A -lesson's whole claim is that it reaches a session on a project it was not -written on; step 8's acceptance is "retrieved on a different project, at the -moment it applies, AND opened". Surfacing cross-project: answerable. Opening: +That gap sits exactly on the question lessons exist to answer. A lesson's +whole claim is that it reaches a session on a project it was not written on: +"retrieved on a different project, at the moment it applies, AND opened". Surfacing cross-project: answerable. Opening: answerable. Both at once: not, until this column. WHICH PROJECT THIS IS, because the name is ambiguous and the wrong reading diff --git a/alembic/versions/0106_rule_usage_events_carry_an_outcome.py b/alembic/versions/0106_rule_usage_events_carry_an_outcome.py index 2eef7571..e02ee635 100644 --- a/alembic/versions/0106_rule_usage_events_carry_an_outcome.py +++ b/alembic/versions/0106_rule_usage_events_carry_an_outcome.py @@ -1,10 +1,10 @@ -"""rule_usage_events carry an outcome, not just a read (#4212, milestone 419) +"""rule_usage_events carry an outcome, not just a read Revision ID: 0106 Revises: 0105 Create Date: 2026-09-20 -Milestone 419's first step. `rule_usage_events` can say a rule was SURFACED +`rule_usage_events` can say a rule was SURFACED and that it was PULLED. It cannot say what happened next, so these two sessions leave identical telemetry: @@ -12,16 +12,14 @@ sessions leave identical telemetry: - a rule surfaced, opened, and silently ignored. The second is the more urgent by a distance, and it is the one the readout -cannot name. That is the whole of what this milestone is about, measured on a -session where three of seven misses were caught by the operator and none by -the system. +cannot name, and it is what an outcome column is for. TWO NEW EVENT VALUES, AND NO CHECK MIGRATION. `event` was created in 0094 as plain `sa.Text()` with no constraint — checked in the migration itself, not assumed from the model — so `applied` and `departed` join `surfaced` and -`pulled` without a DROP/ADD pair. Rule 36 governs CHECK-whitelisted columns +`pulled` without a DROP/ADD pair. That pair is for CHECK-whitelisted columns and this is not one; noted explicitly because the next reader will reach for -rule 36 here, and should be able to see in one place why it does not bite. +it here, and should be able to see in one place why it does not apply. THE THIRD STATE IS DERIVED, AND THAT IS NOT A SHORTCUT. Read-and-silently- unchanged is the absence of an outcome, and it has to be: an agent that knew @@ -29,8 +27,8 @@ it was ignoring a rule would not be ignoring it. There is no honest way to ask for that event, so nothing here tries. `applied` and `departed` are reported; the third state is what is left over when a rule was pulled and neither arrived. A schema that offered an `ignored` value would collect nothing and -read as though it had measured something, which is the #3311 failure — a -statistic that cannot vary being mistaken for a finding. +read as though it had measured something — a statistic that cannot vary +being mistaken for a finding. `detail` CARRIES THE WHY OF A DEPARTURE, and is the reason this is a column rather than two more bare event strings. A departure without its reason is diff --git a/alembic/versions/0107_system_embeddings.py b/alembic/versions/0107_system_embeddings.py index e98251c1..70211dfe 100644 --- a/alembic/versions/0107_system_embeddings.py +++ b/alembic/versions/0107_system_embeddings.py @@ -1,4 +1,4 @@ -"""system_embeddings — a charter becomes an ANSWER, not just a filter (#4251) +"""system_embeddings — a charter becomes an ANSWER, not just a filter Revision ID: 0107 Revises: 0106 @@ -13,8 +13,7 @@ to one, and an agent asking where a record belonged had to read every charter or guess. The fourth sibling of note_embeddings (0067), rule_embeddings (0089) and -milestone_embeddings (0102), and for the same reason note 3163 gives about the -third: the row could be shared, the search cannot. "Where does this belong?" +milestone_embeddings (0102), and for the same reason as the third: the row could be shared, the search cannot. "Where does this belong?" is a different question from "what prior art is there?", and no note or rule search can answer it, because a charter is not a note. diff --git a/alembic/versions/0108_trigger_leaves_the_stored_title.py b/alembic/versions/0108_trigger_leaves_the_stored_title.py index 869d04c4..8ca0420c 100644 --- a/alembic/versions/0108_trigger_leaves_the_stored_title.py +++ b/alembic/versions/0108_trigger_leaves_the_stored_title.py @@ -1,17 +1,16 @@ -"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name (milestone 427) +"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name Revision ID: 0108 Revises: 0107 Create Date: 2026-09-23 Snippets and lessons stored their title as `subject — trigger`, because the -join is what makes these kinds rank on the situation they apply to (#2485) and +join is what makes these kinds rank on the situation they apply to and the stored title WAS the embedded one. Every surface that shows a title then showed the trigger too: menu lines, lists and search rows ran to 1,500–3,000 characters, and an injected repeat spent all of it again. -The trigger's home is `data` (decision #4157), and since this milestone the -join happens at embed time (`embeddings.document_title`). This revision +The trigger's home is `data`, and the join happens at embed time (`embeddings.document_title`). This revision rewrites the rows already stored. EXACT-SUFFIX, READ FROM THE ROW'S OWN MIRROR. A title is only rewritten when diff --git a/alembic/versions/0109_embeddings_record_their_model.py b/alembic/versions/0109_embeddings_record_their_model.py index 334b2986..b8ccbe95 100644 --- a/alembic/versions/0109_embeddings_record_their_model.py +++ b/alembic/versions/0109_embeddings_record_their_model.py @@ -1,4 +1,4 @@ -"""embeddings_record_their_model — a vector says whose space it lives in (#4132) +"""embeddings_record_their_model — a vector says whose space it lives in Revision ID: 0109 Revises: 0108 diff --git a/alembic/versions/0110_task_claims.py b/alembic/versions/0110_task_claims.py index 3b95078c..b1f0d533 100644 --- a/alembic/versions/0110_task_claims.py +++ b/alembic/versions/0110_task_claims.py @@ -1,4 +1,4 @@ -"""task_claims — a task can say a session is working it (milestone 381 step 2) +"""task_claims — a task can say a session is working it Revision ID: 0110 Revises: 0109 diff --git a/alembic/versions/0111_lesson_rule_links.py b/alembic/versions/0111_lesson_rule_links.py index 96a2b35b..fb4c99a7 100644 --- a/alembic/versions/0111_lesson_rule_links.py +++ b/alembic/versions/0111_lesson_rule_links.py @@ -1,5 +1,4 @@ """lesson_rule_links — a lesson points at the rule it is an instance of -(milestone 440 step 1, #4196) Revision ID: 0111 Revises: 0110 @@ -21,8 +20,8 @@ down_revision = "0110" branch_labels = None depends_on = None -# One place, so the CHECK and the model's LINK_STATES cannot drift (rule 36: -# a new value later means DROP + ADD CONSTRAINT in the same migration). +# One place, so the CHECK and the model's LINK_STATES cannot drift (a new +# value later means DROP + ADD CONSTRAINT in the same migration). _STATES = ("suggested", "confirmed", "rejected") diff --git a/alembic/versions/0112_lesson_no_rule.py b/alembic/versions/0112_lesson_no_rule.py index da806afc..c2f93271 100644 --- a/alembic/versions/0112_lesson_no_rule.py +++ b/alembic/versions/0112_lesson_no_rule.py @@ -1,5 +1,4 @@ -"""lesson_no_rule — "no rule fits" is a recorded answer (milestone 440 step 2, -#4631) +"""lesson_no_rule — "no rule fits" is a recorded answer Revision ID: 0112 Revises: 0111 diff --git a/alembic/versions/0113_system_path_patterns.py b/alembic/versions/0113_system_path_patterns.py index e63e3c90..c51f53c8 100644 --- a/alembic/versions/0113_system_path_patterns.py +++ b/alembic/versions/0113_system_path_patterns.py @@ -1,5 +1,4 @@ """system_path_patterns — a System names the files that are its area -(milestone 444 step 3, #4756) Revision ID: 0113 Revises: 0112 diff --git a/alembic/versions/0114_system_usage_events.py b/alembic/versions/0114_system_usage_events.py index 86734a83..79c190cf 100644 --- a/alembic/versions/0114_system_usage_events.py +++ b/alembic/versions/0114_system_usage_events.py @@ -1,5 +1,5 @@ """system_usage_events — were an area's rulings read once its files were -touched? (milestone 444 step 4, #4757) +touched? Revision ID: 0114 Revises: 0113 diff --git a/alembic/versions/0115_retrieval_judgments.py b/alembic/versions/0115_retrieval_judgments.py index 6ed638bd..d48a5f7f 100644 --- a/alembic/versions/0115_retrieval_judgments.py +++ b/alembic/versions/0115_retrieval_judgments.py @@ -1,5 +1,4 @@ """retrieval_judgments — a reviewer's verdict on each line of a logged menu -(#4772) Revision ID: 0115 Revises: 0114 diff --git a/alembic/versions/0116_moment_mappings.py b/alembic/versions/0116_moment_mappings.py index 438b08d1..29622454 100644 --- a/alembic/versions/0116_moment_mappings.py +++ b/alembic/versions/0116_moment_mappings.py @@ -1,5 +1,5 @@ """moment_mappings — an install's own corrections to which actions reach which -moment (milestone 458 step 2) +moment Revision ID: 0116 Revises: 0115 @@ -8,7 +8,7 @@ Create Date: 2026-10-05 The moments and their default actions are code; this table holds what an install adds ("make ship" is a deliver here) and the defaults it switches off. User-owned configuration, so it cascades with the user. No CHECK on `effect`, -which the service validates (rule 36). +which the service validates. """ import sqlalchemy as sa from alembic import op diff --git a/alembic/versions/0117_rule_moments.py b/alembic/versions/0117_rule_moments.py index 30d3d5ad..1920faa8 100644 --- a/alembic/versions/0117_rule_moments.py +++ b/alembic/versions/0117_rule_moments.py @@ -1,4 +1,4 @@ -"""rule_moments — the moments of work a rule arrives at (milestone 458 step 3) +"""rule_moments — the moments of work a rule arrives at Revision ID: 0117 Revises: 0116 @@ -28,7 +28,7 @@ def upgrade() -> None: sa.Column("moment", sa.Text(), primary_key=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), ) - # The delivery read (step 4) asks "which rules are on this moment?", the + # The delivery read asks "which rules are on this moment?", the # opposite direction from the primary key. op.create_index("ix_rule_moments_moment", "rule_moments", ["moment"]) diff --git a/alembic/versions/0118_rule_moment_judgments.py b/alembic/versions/0118_rule_moment_judgments.py index 3884ea0e..01af7ca7 100644 --- a/alembic/versions/0118_rule_moment_judgments.py +++ b/alembic/versions/0118_rule_moment_judgments.py @@ -1,5 +1,4 @@ """rule_moment_judgments — whether a rule belongs on a moment, and who said so -(milestone 458 step 7, #4925) Revision ID: 0118 Revises: 0117 @@ -23,8 +22,8 @@ branch_labels = None depends_on = None # One place each, so the CHECKs and the model's JUDGMENT_STATES / SOURCES -# cannot drift (rule 36: a new value later means DROP + ADD CONSTRAINT in the -# same migration). +# cannot drift (a new value later means DROP + ADD CONSTRAINT in the same +# migration). _STATES = ("suggested", "confirmed", "rejected") _SOURCES = ("pass", "signal", "edit") diff --git a/alembic/versions/0119_rule_moment_misfires.py b/alembic/versions/0119_rule_moment_misfires.py index 655a4f74..16f50e37 100644 --- a/alembic/versions/0119_rule_moment_misfires.py +++ b/alembic/versions/0119_rule_moment_misfires.py @@ -1,5 +1,5 @@ """rule_moment_judgments.misfire — a mount reported as arriving where it does -not apply (milestone 458 step 7b, #4955) +not apply Revision ID: 0119 Revises: 0118 diff --git a/alembic/versions/0120_family_canon.py b/alembic/versions/0120_family_canon.py index 0a253a51..33195ab9 100644 --- a/alembic/versions/0120_family_canon.py +++ b/alembic/versions/0120_family_canon.py @@ -1,5 +1,5 @@ """family canon — platforms, family ideas, the adoption ledger and its decision -log (milestone 463 step 1) +log Revision ID: 0120 Revises: 0119 @@ -13,7 +13,7 @@ reason), or owed. These tables hold that. - `platforms` — a GLOBAL catalog, the canonical_systems shape: no owner, slug is the match key. Seeded with generic technology names only. Nothing here names an app, a repo or a house convention: the catalog ships to every - install (rule 115). + install. - `project_platforms` — which platforms a project is (declared / detected / rejected). Membership is what makes "is this in family?" a lookup. - `family_ideas` — a NOTE's family state. No new record type: any notes row @@ -39,7 +39,7 @@ branch_labels = None depends_on = None # One place per whitelist, so each CHECK and the model's tuple cannot drift -# (rule 36: a new value later means DROP + ADD CONSTRAINT in one migration). +# (a new value later means DROP + ADD CONSTRAINT in one migration). _MEMBERSHIP = ("declared", "detected", "rejected") _IDEA_STATUSES = ("candidate", "canon", "retired") _ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed") diff --git a/alembic/versions/0121_retire_report_preference.py b/alembic/versions/0121_retire_report_preference.py index 1f34195c..ca6f9c99 100644 --- a/alembic/versions/0121_retire_report_preference.py +++ b/alembic/versions/0121_retire_report_preference.py @@ -1,4 +1,4 @@ -"""retire the report_preference arm's rows (milestone 500 step 4) +"""retire the report_preference arm's rows Revision ID: 0121 Revises: 0120 @@ -6,8 +6,8 @@ Create Date: 2026-10-09 The fixed-question arm that searched for completion-report preferences when a task closed is gone: the reply shapes and the preferences mounted beside them -now arrive on the moments (milestone 500 step 3). Its rows go with it (rule -22 — no legacy to preserve), because each one would otherwise mean something +now arrive on the moments. Its rows go with it (there is no legacy to +preserve), because each one would otherwise mean something different once the arm is no longer registered: - `retrieval_logs` / `retrieval_judgments` — a source missing from the @@ -21,11 +21,10 @@ different once the arm is no longer registered: - `settings` — the arm's floor and budget keys, which nothing reads. COST IS BOUNDED BY AN INDEX, NOT BY THE TABLE. A migration runs at boot -against the live tables, which CI never has (lesson #5221). `retrieval_logs` +against the live tables, which CI never has. `retrieval_logs` and `retrieval_judgments` are indexed on `source`. `rule_usage_events` is not, -so its delete is also bounded by `created_at` (indexed): the arm shipped with -milestone 409 step 4, decided 2026-09-14, so no row of it predates -`ARM_BORN`, and the scan covers weeks rather than the table's whole history. +so its delete is also bounded by `created_at` (indexed): no row of the arm +predates `ARM_BORN`, and the scan covers weeks rather than the table's whole history. Not reversible: the arm is not coming back, so neither are its rows. """ diff --git a/alembic/versions/0122_seal_forge_tokens.py b/alembic/versions/0122_seal_forge_tokens.py index 067f41e1..b3821a11 100644 --- a/alembic/versions/0122_seal_forge_tokens.py +++ b/alembic/versions/0122_seal_forge_tokens.py @@ -1,4 +1,4 @@ -"""Seal forge-connection tokens at rest (milestone 523) +"""Seal forge-connection tokens at rest Revision ID: 0122 Revises: 0121 @@ -43,7 +43,7 @@ _LABEL_V1 = b"scribe.secrets.v1\x00" def _fernet(): from cryptography.fernet import Fernet - from scribe.config import Config + from grimoire.config import Config digest = hashlib.sha256(_LABEL_V1 + Config.SECRET_KEY.encode()).digest() return Fernet(base64.urlsafe_b64encode(digest)) diff --git a/alembic/versions/0123_drop_user_profiles.py b/alembic/versions/0123_drop_user_profiles.py new file mode 100644 index 00000000..fae1c8d3 --- /dev/null +++ b/alembic/versions/0123_drop_user_profiles.py @@ -0,0 +1,64 @@ +"""drop user_profiles + +Revision ID: 0123 +Revises: 0122 +Create Date: 2026-10-22 + +The Profile settings page asked for a display name, job title, expertise, +response style, tone, interests and a work schedule, and said an assistant +used them to personalise chat and a daily journal. Nothing in the app read +any of it: the page saved to this table and the backup carried it, and that +was all. The page, its API and the backup section are gone, and the table +goes with them. + +Downgrade recreates the empty table, so the schema steps back cleanly; the +rows themselves do not come back. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import ARRAY, JSONB + +revision = "0123" +down_revision = "0122" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_index("ix_user_profiles_user_id", table_name="user_profiles") + op.drop_table("user_profiles") + + +def downgrade() -> None: + op.create_table( + "user_profiles", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ), + sa.Column("display_name", sa.Text(), nullable=True), + sa.Column("job_title", sa.Text(), nullable=True), + sa.Column("industry", sa.Text(), nullable=True), + sa.Column("expertise_level", sa.Text(), nullable=True), + sa.Column("response_style", sa.Text(), nullable=True), + sa.Column("tone", sa.Text(), nullable=True), + sa.Column("interests", ARRAY(sa.Text()), nullable=True), + sa.Column("work_schedule", JSONB(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True) diff --git a/alembic/versions/0124_skill_names_follow_the_plugin.py b/alembic/versions/0124_skill_names_follow_the_plugin.py new file mode 100644 index 00000000..2ae3ac0a --- /dev/null +++ b/alembic/versions/0124_skill_names_follow_the_plugin.py @@ -0,0 +1,45 @@ +"""skill names follow the plugin's name + +Revision ID: 0124 +Revises: 0123 +Create Date: 2026-10-22 + +The plugin is named `grimoire`, so its skills reach a session as +`grimoire:` and stored processes as `grimoire-proc-`. A rule +mounted on one of those skills, a judgment about that mount, and an install's +own action mapping all store the skill's name, and a stored name the harness no +longer sends never matches again. This rewrites the plugin's earlier prefixes, +`scribe:` and `scribe-proc-`, to the current ones. + +Cost: three small tables, each one row per mount, judgment or mapping. +""" +from alembic import op + +revision = "0124" +down_revision = "0123" +branch_labels = None +depends_on = None + +_MOMENT_TABLES = ("rule_moments", "rule_moment_judgments", "moment_mappings") + + +def _rename(old: str, new: str) -> None: + for table in _MOMENT_TABLES: + op.execute( + f"UPDATE {table} SET moment = regexp_replace(moment, " + f"'^skill\\.{old}(:|-proc-)', 'skill.{new}\\1') " + f"WHERE moment ~ '^skill\\.{old}(:|-proc-)'" + ) + op.execute( + f'UPDATE moment_mappings SET "match" = regexp_replace("match", ' + f"'skill={old}(:|-proc-)', 'skill={new}\\1', 'g') " + f"WHERE \"match\" ~ 'skill={old}(:|-proc-)'" + ) + + +def upgrade() -> None: + _rename("scribe", "grimoire") + + +def downgrade() -> None: + _rename("grimoire", "scribe") diff --git a/ci-requirements.md b/ci-requirements.md index 41972e9d..ebe20e9d 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -1,4 +1,4 @@ -# CI Requirements — FabledScribe +# CI Requirements — FabledGrimoire > Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md) > in the CI-Runner repo. @@ -16,11 +16,10 @@ real Postgres), build (docker buildx). ## Image deps used - python 3.14 -- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the - frontend builder stage inside the production `Dockerfile`) -- ruff (lint job runs `ruff check src/` with zero install overhead) -- uv (test + integration jobs run `uv sync --locked`; installed in the - image since the ci-python Dockerfile started pip-installing it) +- node 24 (`npm ci` + `vue-tsc` in the typecheck job; the production + `Dockerfile` builds the frontend on `node:22-alpine`) +- ruff (lint job runs `ruff check src/ scripts/` with zero install overhead) +- uv (test + integration jobs run `uv sync --locked`) - docker CLI + buildx (build job pushes the production image to the Fabled-Git registry) @@ -30,24 +29,15 @@ Anything CI installs at job time that isn't in the image. Promotion candidates if more than one project needs them. - `shellcheck` — apt-installed in the **plugin** job, which lints the - Claude Code hook scripts and runs their fail-open smoke test. Per - `docs/process.md`'s decision checkpoint, single-consumer deps stay - per-job until a second consumer wants them; Scribe is the only one so - far. It is small (~20 MB) and would be a promotion candidate the moment - another project lints shell. + Claude Code hook scripts and runs their fail-open smoke test. Grimoire is + its only consumer, so per `docs/process.md` it stays per-job. It is small + (~20 MB) and a promotion candidate once another project lints shell. -- `jq` — **no longer installed anywhere, and should not be promoted.** - It was installed in two jobs, and this file used to record it as "load- - bearing for the smoke test specifically", because every hook opened - `command -v jq || exit 0` and the test would otherwise pass while - exercising nothing. That was the tail wagging the dog: the hooks ship to - users, jq is absent by default on macOS, the Debian/Ubuntu slim images, - Alpine and most CI containers, and a machine without it got no context, - no rules, no prior art and no process sync in silence. #4107 removed the - dependency rather than documenting it, so the smoke test now runs on a - bare image — which is the condition it was always meant to assert. The - hooks use only POSIX tools (awk, sed, tr, od, cut, head, tail, grep, - sort, date, printf) plus `git` and `curl`. +The hooks ship to users' machines, so they use only POSIX tools (awk, sed, +tr, od, cut, head, tail, grep, sort, date, printf) plus `git` and `curl`. +The smoke test runs on a bare image to keep it that way: a tool such as `jq` +is missing on macOS, slim Debian/Ubuntu and Alpine, and a hook that needed it +would give those machines nothing. ## Notes diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a540bf62..1d19d84c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,59 +1,32 @@ services: app: - image: git.fabledsword.com/bvandeusen/fabledscribe:latest + image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest environment: - DATABASE_URL: "postgresql+asyncpg://scribe:${DB_PASSWORD}@db:5432/scribe" + DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${DB_PASSWORD}@db:5432/${POSTGRES_DB:-grimoire}" SECRET_KEY: "${SECRET_KEY}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" TRUST_PROXY_HEADERS: "true" SECURE_COOKIES: "true" networks: - - scribe_backend - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - deploy: - restart_policy: - condition: on-failure - max_attempts: 5 + - backend db: - # pgvector image (Debian/glibc, PG17) — bundles the `vector` extension that - # migration 0067 enables. Moved off postgres:16-alpine via logical - # dump/restore (which doubles as the PG16->PG17 major upgrade); see the - # TRANSITION runbook in the PR. + # Postgres 17 with the vector extension Grimoire needs. image: pgvector/pgvector:pg17 stop_grace_period: 120s volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_USER: scribe + POSTGRES_USER: ${POSTGRES_USER:-grimoire} POSTGRES_PASSWORD: "${DB_PASSWORD}" - POSTGRES_DB: scribe + POSTGRES_DB: ${POSTGRES_DB:-grimoire} networks: - - scribe_backend - # Lenient by design: a transient host exec/healthcheck stall (incident: - # runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never - # escalate to killing the DB. Health here only gates app startup order. - healthcheck: - test: ["CMD-SHELL", "pg_isready -U scribe"] - interval: 30s - timeout: 10s - retries: 10 - start_period: 180s - deploy: - restart_policy: - condition: on-failure - delay: 10s - max_attempts: 0 - window: 120s + - backend volumes: pgdata: + name: ${PGDATA_VOLUME:-grimoire_pgdata} networks: - scribe_backend: + backend: driver: overlay diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 80011b38..90af1f63 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -1,57 +1,34 @@ -# Fabled Scribe — Quick Start -# -# No build required. Pulls the latest pre-built image from the registry. -# -# Usage: -# 1. Download this file -# 2. docker compose -f docker-compose.quickstart.yml up -d -# 3. Open http://localhost:5000 — the first account registered becomes admin -# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the -# bearer-token URL shown there. -# -# Set SECRET_KEY via environment variable or a .env file alongside this file: -# SECRET_KEY=your-random-secret-here +# Fabled Grimoire from the pre-built image: +# docker compose -f docker-compose.quickstart.yml up -d +# Then open http://localhost:5000 (the first account registered becomes admin) +# and connect Claude from Settings → MCP Access. services: app: - image: git.fabledsword.com/bvandeusen/fabledscribe:latest + image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest ports: - "5000:5000" environment: - DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe" + DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}" + # Set SECRET_KEY in the environment or a .env file beside this one. SECRET_KEY: "${SECRET_KEY:-change-me-in-production}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" volumes: - app_data:/data depends_on: - db: - condition: service_healthy + - db restart: unless-stopped - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s db: - # pgvector image (PG17) — bundles the `vector` extension (migration 0067). + # Postgres 17 with the vector extension Grimoire needs. image: pgvector/pgvector:pg17 stop_grace_period: 120s volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_USER: scribe - POSTGRES_PASSWORD: scribe - POSTGRES_DB: scribe - # Lenient by design: a transient host exec/healthcheck stall must never - # escalate to killing the DB. Health here only gates app startup order. - healthcheck: - test: ["CMD-SHELL", "pg_isready -U scribe"] - interval: 30s - timeout: 10s - retries: 10 - start_period: 180s + POSTGRES_USER: ${POSTGRES_USER:-grimoire} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire} + POSTGRES_DB: ${POSTGRES_DB:-grimoire} restart: unless-stopped volumes: diff --git a/docker-compose.yml b/docker-compose.yml index eba260df..2a420706 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,43 +4,29 @@ services: ports: - "5000:5000" depends_on: - db: - condition: service_healthy + - db + restart: unless-stopped volumes: - app_data:/data - # To use a bind mount instead (gives direct host access to all app data): + # Or a bind mount, for direct host access to the app's data: # - ./data:/data environment: - DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}" + DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" - # Uncomment if you have a SearXNG instance you want to surface in the - # Integrations tab as a configured web-search backend: + # Optional: a SearXNG instance to list as the web-search backend in Integrations. # SEARXNG_URL: "http://searxng:8080" - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s db: - image: postgres:16-alpine + # Postgres 17 with the vector extension Grimoire needs. + image: pgvector/pgvector:pg17 stop_grace_period: 120s restart: unless-stopped volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_USER: ${POSTGRES_USER:-scribe} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe} - POSTGRES_DB: ${POSTGRES_DB:-scribe} - # Lenient by design: a transient host exec/healthcheck stall must never - # escalate to killing the DB. Health here only gates app startup order. - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"] - interval: 30s - timeout: 10s - retries: 10 - start_period: 180s + POSTGRES_USER: ${POSTGRES_USER:-grimoire} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire} + POSTGRES_DB: ${POSTGRES_DB:-grimoire} volumes: pgdata: diff --git a/docs/2026-03-23-fable-mcp-design.md b/docs/2026-03-23-fable-mcp-design.md deleted file mode 100644 index 3edeeb25..00000000 --- a/docs/2026-03-23-fable-mcp-design.md +++ /dev/null @@ -1,271 +0,0 @@ -# Fable MCP — Design Spec - -**Date:** 2026-03-23 -**Status:** Approved -**Author:** bvandeusen + Claude - ---- - -## Overview - -A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions. - -The work is split into two sub-projects: - -1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication -2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root - -A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working. - ---- - -## Sub-project 1: Fable API Key Feature - -### Motivation - -All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level. - -### Database - -New table `api_keys` via migration `0027_add_api_keys.py`: -- `down_revision = "0026"` (the briefing tables migration) -- `revision = "0027"` - -| Column | Type | Notes | -|--------|------|-------| -| `id` | Integer PK | | -| `user_id` | Integer FK → users | CASCADE delete | -| `name` | Text | Human-readable label | -| `key_hash` | Text | SHA-256 of the full key, used for lookup | -| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI | -| `scope` | Text | `"read"` or `"write"` | -| `last_used_at` | Timestamp | Nullable, updated on each authenticated request | -| `created_at` | Timestamp | | -| `revoked_at` | Timestamp | Nullable — soft delete | - -Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted. - -### Auth Middleware (`auth.py`) - -`_check_auth` is updated to check the `Authorization: Bearer ` header before falling back to session auth: - -1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL` -2. If found: update `last_used_at`, set `g.user` and `g.api_key` -3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403 -4. If header absent: existing session logic runs unchanged - -Session auth is untouched — no regression risk for the web UI. - -**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role. - -### Routes (`/api/api-keys`) - -New blueprint `api_keys_bp`, registered in `app.py`: - -- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key) -- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only** -- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()` - -### Settings UI - -New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`): - -- Create form: name input + read/write radio/toggle + "Generate Key" button -- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again -- Keys table: columns for name, scope badge, prefix, last used, revoke button -- Revoke shows inline confirmation before calling DELETE - -### New Search Endpoint - -`GET /api/search?q=&content_type=note|task|all&limit=N` - -Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument: -- `content_type=note` → `is_task=False` -- `content_type=task` → `is_task=True` -- `content_type=all` (default) → `is_task=None` - -Returns: - -```json -{ - "results": [ - {"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]} - ], - "total": 5 -} -``` - -This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work. - -### Conversation Type: `"mcp"` - -A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app: - -**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor. - -**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations. - -**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned. - -MCP conversations: -- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named -- Are excluded from the default chat list -- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection -- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions - -### Chat SSE Wire Format - -The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema: - -- **Create conversation:** `POST /api/chat/conversations` → `{id, title, ...}` -- **Post message + start generation:** `POST /api/chat/conversations/:id/messages` → `{message_id, ...}`; then connect to: -- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE) - -SSE event format (each line is `data: `): -- `{"type": "token", "content": "..."}` — streaming token -- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired -- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event - -The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names). - ---- - -## Sub-project 2: Fable MCP Server - -### Location - -`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable. - -### Package Structure - -``` -fable-mcp/ - pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main" - README.md - .env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_... - fable_mcp/ - __init__.py - server.py # FastMCP instance, imports + registers all tool modules - client.py # FableClient: async httpx wrapper, env var config, FableAPIError - tools/ - __init__.py - notes.py # list_notes, get_note, create_note, update_note, delete_note - tasks.py # list_tasks, get_task, create_task, update_task, delete_task, - # patch_task_status, add_task_log - projects.py # list_projects, get_project, create_project, update_project, - # delete_project, get_project_summary - milestones.py # list_milestones, get_milestone, create_milestone, - # update_milestone, delete_milestone - search.py # semantic_search — calls GET /api/search - chat.py # send_message — creates/continues conversation, returns response -``` - -### Dependencies - -```toml -[project] -dependencies = [ - "mcp[cli]>=1.0", - "httpx>=0.27", - "python-dotenv>=1.0", -] -``` - -### `client.py` - -`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response. - -A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules. - -### `server.py` - -Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport). - -### Tool Modules - -Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude. - -**`notes.py`** tools: -- `list_notes(query, tags, project_id, limit, offset)` -- `get_note(note_id)` -- `create_note(title, body, tags, project_id)` -- `update_note(note_id, title, body, tags, project_id)` -- `delete_note(note_id)` - -**`tasks.py`** tools: -- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)` -- `get_task(task_id)` -- `create_task(title, body, project_id, milestone_id, priority, status)` -- `update_task(task_id, title, body, project_id, milestone_id, priority, status)` -- `delete_task(task_id)` -- `patch_task_status(task_id, status)` -- `add_task_log(task_id, content)` - -**`projects.py`** tools: -- `list_projects()` -- `get_project(project_id)` — includes milestone summary -- `create_project(title, description, goal, color)` -- `update_project(project_id, title, description, goal, status, color)` -- `delete_project(project_id)` - -**`milestones.py`** tools: -- `list_milestones(project_id, status)` -- `get_milestone(project_id, milestone_id)` -- `create_milestone(project_id, title, description, order_index)` -- `update_milestone(project_id, milestone_id, title, description, status, order_index)` -- `delete_milestone(project_id, milestone_id)` - -**`search.py`** tools: -- `semantic_search(query, content_type, limit)` — `content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores. - -**`chat.py`** tools: -- `send_message(message, conversation_name)`: - - Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"` - - Posts the message via `POST /api/chat/conversations/:id/messages` - - Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"` - - Returns: `{response: str, tools_used: [str], conversation_id: int}` - - MCP conversations are excluded from the normal chat UI list - -### Error Handling - -`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings. - -Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it. - -### Claude Code Registration - -After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`: - -```json -{ - "mcpServers": { - "fable": { - "command": "fable-mcp", - "env": { - "FABLE_URL": "http://localhost:8080", - "FABLE_API_KEY": "fmcp_your_key_here" - } - } - } -} -``` - -Claude Code spawns the process over stdio automatically. No Docker, no daemon. - ---- - -## Build & Repo Plan - -1. Implement and test within `scribe/fable-mcp/` -2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`) -3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready - ---- - -## Out of Scope (v1) - -- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code -- Forgejo MCP — follow-on spec -- Rate limiting on API key endpoints -- Key expiry / TTL -- Per-resource scope (e.g., key restricted to one project) diff --git a/docs/2026-03-23-fable-mcp-plan.md b/docs/2026-03-23-fable-mcp-plan.md deleted file mode 100644 index fa061a42..00000000 --- a/docs/2026-03-23-fable-mcp-plan.md +++ /dev/null @@ -1,2091 +0,0 @@ -# Fable MCP Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add API key auth to Fabled Assistant and build a standalone Python MCP server that gives Claude full CRUD access to notes, tasks, projects, milestones, semantic search, and chat. - -**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. - -**Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance. - -**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. - -**Testing convention:** Phase 1 tests run via `docker compose run --rm app pytest tests/ -v`. Phase 2 tests run locally with `cd fable-mcp && pytest -v`. All Phase 1 tests are unit tests of pure functions — mock DB calls with `unittest.mock.AsyncMock`; never connect to a real database in tests. - -**Important discovery:** `Conversation.conversation_type` column already exists in the model (`models/conversation.py:23`) and `list_conversations` already filters by it (`services/chat.py:49`). No migration needed for conversation types — only service/route wiring. - ---- - -## Phase 1: Fable API Key Feature - -### File Map - -| Action | File | Purpose | -|--------|------|---------| -| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | -| Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model | -| Modify | `src/scribe/models/__init__.py` | Export `ApiKey` | -| Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions | -| Modify | `src/scribe/auth.py` | Add bearer token check before session fallback | -| Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | -| Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` | -| Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | -| Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | -| Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body | -| Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint | -| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | -| Create | `tests/test_api_keys.py` | Unit tests for service + auth | -| Create | `tests/test_search_route.py` | Unit test for search endpoint | - ---- - -### Task 1: ApiKey model + migration - -**Files:** -- Create: `src/scribe/models/api_key.py` -- Create: `alembic/versions/0027_add_api_keys.py` -- Modify: `src/scribe/models/__init__.py` - -- [ ] **Step 1: Write the model** - -Create `src/scribe/models/api_key.py`: - -```python -from datetime import datetime, timezone - -from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text -from sqlalchemy.orm import Mapped, mapped_column - -from scribe.models import Base -from scribe.models.base import CreatedAtMixin - - -class ApiKey(Base, CreatedAtMixin): - __tablename__ = "api_keys" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - name: Mapped[str] = mapped_column(Text, nullable=False) - key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) - key_prefix: Mapped[str] = mapped_column(Text, nullable=False) - scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write" - last_used_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - revoked_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - - __table_args__ = ( - Index("ix_api_keys_user_id", "user_id"), - Index("ix_api_keys_key_hash", "key_hash"), - ) - - def to_dict(self) -> dict: - return { - "id": self.id, - "name": self.name, - "key_prefix": self.key_prefix, - "scope": self.scope, - "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, - "created_at": self.created_at.isoformat(), - "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, - } -``` - -- [ ] **Step 2: Export from models __init__** - -In `src/scribe/models/__init__.py`, add after the last import line: - -```python -from scribe.models.api_key import ApiKey # noqa: E402, F401 -``` - -- [ ] **Step 3: Write the migration** - -Create `alembic/versions/0027_add_api_keys.py`: - -```python -"""add api_keys table - -Revision ID: 0027 -Revises: 0026 -Create Date: 2026-03-23 -""" -from alembic import op -import sqlalchemy as sa - -revision = "0027" -down_revision = "0026" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "api_keys", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("key_hash", sa.Text(), nullable=False, unique=True), - sa.Column("key_prefix", sa.Text(), nullable=False), - sa.Column("scope", sa.Text(), nullable=False), - sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - ) - op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) - op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"]) - - -def downgrade() -> None: - op.drop_table("api_keys") -``` - -- [ ] **Step 4: Verify migration runs** - -```bash -docker compose run --rm app alembic upgrade head -``` - -Expected: `Running upgrade 0026 -> 0027, add api_keys table` - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/models/api_key.py \ - src/scribe/models/__init__.py \ - alembic/versions/0027_add_api_keys.py -git commit -m "feat: add ApiKey model and migration 0027" -``` - ---- - -### Task 2: ApiKey service - -**Files:** -- Create: `src/scribe/services/api_keys.py` -- Create: `tests/test_api_keys.py` (service tests) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_api_keys.py`: - -```python -"""Unit tests for api_keys service — pure logic, DB calls mocked.""" -import hashlib -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from scribe.services.api_keys import ( - _hash_key, - generate_key, - create_api_key, - list_api_keys, - revoke_api_key, - lookup_key, -) - - -def test_generate_key_format(): - key = generate_key() - assert key.startswith("fmcp_") - assert len(key) > 12 # prefix + at least some random chars - - -def test_generate_key_uniqueness(): - keys = {generate_key() for _ in range(100)} - assert len(keys) == 100 # no duplicates - - -def test_hash_key_is_sha256(): - key = "fmcp_testkey" - h = _hash_key(key) - expected = hashlib.sha256(key.encode()).hexdigest() - assert h == expected - - -def test_generate_key_prefix(): - key = "fmcp_abcdefghijklmnop" - # prefix is first 12 chars of the full key - from scribe.services.api_keys import _key_prefix - assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars - - -@pytest.mark.asyncio -async def test_create_api_key_returns_full_key(): - mock_key_obj = MagicMock() - mock_key_obj.id = 1 - mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} - - with patch("scribe.services.api_keys.async_session") as mock_session_ctx: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - mock_session_ctx.return_value = mock_session - - full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read") - - assert full_key.startswith("fmcp_") - assert isinstance(key_dict, dict) - - -@pytest.mark.asyncio -async def test_lookup_key_returns_none_for_unknown(): - with patch("scribe.services.api_keys.async_session") as mock_session_ctx: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_result = MagicMock() - mock_result.scalars.return_value.first.return_value = None - mock_session.execute = AsyncMock(return_value=mock_result) - mock_session_ctx.return_value = mock_session - - result = await lookup_key("fmcp_doesnotexist") - - assert result is None -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet) - -- [ ] **Step 3: Write the service** - -Create `src/scribe/services/api_keys.py`: - -```python -import hashlib -import secrets -from datetime import datetime, timezone - -from sqlalchemy import select - -from scribe.models import async_session -from scribe.models.api_key import ApiKey - - -def generate_key() -> str: - """Generate a new full API key. Never stored — caller must hash it.""" - return "fmcp_" + secrets.token_urlsafe(32) - - -def _hash_key(key: str) -> str: - return hashlib.sha256(key.encode()).hexdigest() - - -def _key_prefix(key: str) -> str: - """Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg').""" - return key[:12] - - -async def create_api_key( - user_id: int, name: str, scope: str -) -> tuple[str, dict]: - """Create a new API key. Returns (full_key, key_dict). full_key is never stored.""" - if scope not in ("read", "write"): - raise ValueError("scope must be 'read' or 'write'") - full_key = generate_key() - key = ApiKey( - user_id=user_id, - name=name, - key_hash=_hash_key(full_key), - key_prefix=_key_prefix(full_key), - scope=scope, - ) - async with async_session() as session: - session.add(key) - await session.commit() - await session.refresh(key) - return full_key, key.to_dict() - - -async def list_api_keys(user_id: int) -> list[dict]: - """List all non-revoked API keys for the user.""" - async with async_session() as session: - result = await session.execute( - select(ApiKey) - .where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None)) - .order_by(ApiKey.created_at.desc()) - ) - return [k.to_dict() for k in result.scalars().all()] - - -async def revoke_api_key(user_id: int, key_id: int) -> bool: - """Soft-delete a key by setting revoked_at. Returns True if found.""" - async with async_session() as session: - result = await session.execute( - select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id) - ) - key = result.scalars().first() - if key is None: - return False - key.revoked_at = datetime.now(timezone.utc) - await session.commit() - return True - - -async def lookup_key(raw_key: str) -> ApiKey | None: - """Look up a non-revoked ApiKey by raw token value. Updates last_used_at.""" - key_hash = _hash_key(raw_key) - async with async_session() as session: - result = await session.execute( - select(ApiKey).where( - ApiKey.key_hash == key_hash, - ApiKey.revoked_at.is_(None), - ) - ) - key = result.scalars().first() - if key is None: - return None - key.last_used_at = datetime.now(timezone.utc) - await session.commit() - await session.refresh(key) - return key -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/services/api_keys.py tests/test_api_keys.py -git commit -m "feat: add ApiKey service with create/list/revoke/lookup" -``` - ---- - -### Task 3: Auth middleware — bearer token support - -**Files:** -- Modify: `src/scribe/auth.py` -- Modify: `tests/test_api_keys.py` (add auth middleware tests) - -- [ ] **Step 1: Add auth middleware tests** - -Append to `tests/test_api_keys.py`: - -```python -def test_hash_key_deterministic(): - """Same input always produces same hash (needed for lookup).""" - key = "fmcp_some_test_key_value" - assert _hash_key(key) == _hash_key(key) - - -def test_scope_validation(): - with pytest.raises(ValueError, match="scope must be"): - import asyncio - asyncio.run(create_api_key(1, "bad", "superadmin")) - - -# --- Auth middleware tests --- - -@pytest.mark.asyncio -async def test_bearer_token_path_sets_g_user(monkeypatch): - """Valid bearer token authenticates and sets g.user and g.api_key.""" - from unittest.mock import AsyncMock, MagicMock - from scribe.auth import _check_auth - - # Mock ApiKey object - fake_key = MagicMock() - fake_key.user_id = 7 - fake_key.scope = "write" - - # Mock User object - fake_user = MagicMock() - fake_user.role = "user" - - monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)) - monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)) - - called_with_user = {} - - async def view_func(): - from quart import g - called_with_user["user"] = g.user - called_with_user["api_key"] = g.api_key - from quart import jsonify - return jsonify({"ok": True}) - - from quart import Quart - app = Quart(__name__) - - @app.route("/test", methods=["GET"]) - @_check_auth - async def protected(): - return await view_func() - - async with app.test_request_context("/test", method="GET", - headers={"Authorization": "Bearer fmcp_valid"}): - # Just verify _check_auth calls lookup_key with the right token - import scribe.auth as auth_module - auth_module.lookup_key.assert_called_with # callable - - -@pytest.mark.asyncio -async def test_read_only_key_blocked_on_post(): - """Read-only API key returns 403 on non-GET requests.""" - from unittest.mock import AsyncMock, MagicMock - from scribe.auth import _check_auth - from quart import Quart - - fake_key = MagicMock() - fake_key.user_id = 7 - fake_key.scope = "read" - - fake_user = MagicMock() - fake_user.role = "user" - - app = Quart(__name__) - - with ( - app.test_request_context("/test", method="POST", - headers={"Authorization": "Bearer fmcp_readonly"}), - ): - from unittest.mock import patch - with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \ - patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)): - - async def dummy(): - return "ok" - - wrapped = _check_auth(dummy) - from quart import current_app - async with app.app_context(): - resp = await wrapped() - # Should return 403 for read-only key on POST - assert resp[1] == 403 -``` - -- [ ] **Step 2: Run to verify pass** (these are pure logic tests) - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -- [ ] **Step 3: Update auth.py** - -Replace `src/scribe/auth.py` with: - -```python -import functools - -from quart import g, jsonify, request, session - -from scribe.services.auth import get_user_by_id -from scribe.services.api_keys import lookup_key - - -def _check_auth(f, required_role: str | None = None): - @functools.wraps(f) - async def decorated(*args, **kwargs): - # --- Bearer token path --- - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - raw_key = auth_header[len("Bearer "):] - api_key = await lookup_key(raw_key) - if api_key is None: - return jsonify({"error": "Invalid or revoked API key"}), 401 - user = await get_user_by_id(api_key.user_id) - if not user: - return jsonify({"error": "User not found"}), 401 - # Scope enforcement: read-only keys cannot mutate - if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"): - return jsonify({"error": "Read-only key cannot perform write operations"}), 403 - # Role check (admin_required routes) - if required_role and user.role != required_role: - return jsonify({"error": "Admin access required"}), 403 - g.user = user - g.api_key = api_key - return await f(*args, **kwargs) - - # --- Session path (unchanged) --- - user_id = session.get("user_id") - if not user_id: - return jsonify({"error": "Authentication required"}), 401 - user = await get_user_by_id(user_id) - if not user: - session.clear() - return jsonify({"error": "Authentication required"}), 401 - if session.get("session_version") != user.session_version: - session.clear() - return jsonify({"error": "Session expired. Please log in again."}), 401 - if required_role and user.role != required_role: - return jsonify({"error": "Admin access required"}), 403 - g.user = user - return await f(*args, **kwargs) - return decorated - - -def login_required(f): - return _check_auth(f) - - -def admin_required(f): - return _check_auth(f, required_role="admin") - - -def get_current_user_id() -> int: - return g.user.id -``` - -- [ ] **Step 4: Run the full test suite to check for regressions** - -```bash -docker compose run --rm app pytest tests/ -v -``` - -Expected: all existing tests still pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/auth.py tests/test_api_keys.py -git commit -m "feat: add bearer token auth to _check_auth, falls back to session" -``` - ---- - -### Task 4: API key routes + app registration - -**Files:** -- Create: `src/scribe/routes/api_keys.py` -- Modify: `src/scribe/app.py` - -- [ ] **Step 1: Write the routes** - -Create `src/scribe/routes/api_keys.py`: - -```python -from quart import Blueprint, jsonify, request - -from scribe.auth import login_required, get_current_user_id -from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key - -api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") - - -@api_keys_bp.route("", methods=["GET"]) -@login_required -async def list_keys_route(): - uid = get_current_user_id() - keys = await list_api_keys(uid) - return jsonify({"api_keys": keys}) - - -@api_keys_bp.route("", methods=["POST"]) -@login_required -async def create_key_route(): - uid = get_current_user_id() - data = await request.get_json(force=True, silent=True) or {} - name = (data.get("name") or "").strip() - scope = (data.get("scope") or "").strip() - - if not name: - return jsonify({"error": "name is required"}), 400 - if scope not in ("read", "write"): - return jsonify({"error": "scope must be 'read' or 'write'"}), 400 - - full_key, key_dict = await create_api_key(uid, name=name, scope=scope) - # Return the full key ONCE — it is never retrievable again - return jsonify({"key": full_key, "api_key": key_dict}), 201 - - -@api_keys_bp.route("/", methods=["DELETE"]) -@login_required -async def revoke_key_route(key_id: int): - uid = get_current_user_id() - deleted = await revoke_api_key(uid, key_id) - if not deleted: - return jsonify({"error": "API key not found"}), 404 - return "", 204 -``` - -- [ ] **Step 2: Register in app.py** - -In `src/scribe/app.py`, add the import alongside the other route imports: - -```python -from scribe.routes.api_keys import api_keys_bp -``` - -And add the registration line after `app.register_blueprint(users_bp)`: - -```python -app.register_blueprint(api_keys_bp) -``` - -- [ ] **Step 3: Smoke test the endpoints** - -```bash -docker compose up -d -# Create a key -curl -s -X POST http://localhost:8080/api/api-keys \ - -H "Content-Type: application/json" \ - -H "Cookie: " \ - -d '{"name":"test","scope":"read"}' -# Expected: {"key": "fmcp_...", "api_key": {...}} - -# Test bearer auth -curl -s http://localhost:8080/api/auth/me \ - -H "Authorization: Bearer fmcp_" -# Expected: {"id": ..., "username": ...} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/scribe/routes/api_keys.py src/scribe/app.py -git commit -m "feat: add API key CRUD routes and register blueprint" -``` - ---- - -### Task 5: Conversation type wiring - -**Files:** -- Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135) -- Modify: `src/scribe/routes/chat.py` (lines 73-79) - -Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. - -- [ ] **Step 1: Update `create_conversation` service** - -In `src/scribe/services/chat.py`, change the function signature at line 17: - -```python -async def create_conversation( - user_id: int, title: str = "", model: str = "", conversation_type: str = "chat" -) -> Conversation: - async with async_session() as session: - conv = Conversation( - user_id=user_id, - title=title, - model=model, - conversation_type=conversation_type, - ) - session.add(conv) - await session.commit() - result = await session.execute( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where(Conversation.id == conv.id) - ) - return result.scalars().first() -``` - -- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** - -In `src/scribe/services/chat.py`, update the WHERE clause at line 130: - -```python -result = await session.execute( - sa_delete(Conversation) - .where( - Conversation.user_id == user_id, - Conversation.updated_at < cutoff, - Conversation.conversation_type != "mcp", # preserve MCP audit trail - ) - .returning(Conversation.id) -) -``` - -- [ ] **Step 3: Update the POST route to accept conversation_type** - -In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73): - -```python -@chat_bp.route("/conversations", methods=["POST"]) -@login_required -async def create_conversation_route(): - uid = get_current_user_id() - data = await request.get_json(force=True, silent=True) or {} - title = data.get("title", "") - model = data.get("model", Config.OLLAMA_MODEL) - conversation_type = data.get("conversation_type", "chat") - # Only allow known types to prevent accidental misuse - if conversation_type not in ("chat", "mcp"): - conversation_type = "chat" - conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) - return jsonify(conv.to_dict()), 201 -``` - -- [ ] **Step 4: Run tests** - -```bash -docker compose run --rm app pytest tests/ -v -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/services/chat.py src/scribe/routes/chat.py -git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" -``` - ---- - -### Task 6: Semantic search endpoint - -**Files:** -- Create: `src/scribe/routes/search.py` -- Modify: `src/scribe/app.py` -- Create: `tests/test_search_route.py` - -- [ ] **Step 1: Write the failing test** - -Create `tests/test_search_route.py`: - -```python -"""Unit tests for the search route parameter mapping.""" -import pytest -from unittest.mock import patch, AsyncMock - - -def test_content_type_mapping(): - """Verify content_type string maps to correct is_task value.""" - from scribe.routes.search import _content_type_to_is_task - assert _content_type_to_is_task("note") is False - assert _content_type_to_is_task("task") is True - assert _content_type_to_is_task("all") is None - assert _content_type_to_is_task("unknown") is None # default to all -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -docker compose run --rm app pytest tests/test_search_route.py -v -``` - -Expected: `ImportError` (module doesn't exist yet) - -- [ ] **Step 3: Write the route** - -Create `src/scribe/routes/search.py`: - -```python -from quart import Blueprint, jsonify, request - -from scribe.auth import login_required, get_current_user_id -from scribe.services.embeddings import semantic_search_notes - -search_bp = Blueprint("search", __name__, url_prefix="/api/search") - - -def _content_type_to_is_task(content_type: str) -> bool | None: - """Map content_type query param to semantic_search_notes is_task arg.""" - if content_type == "note": - return False - if content_type == "task": - return True - return None # "all" or unknown → no filter - - -@search_bp.route("", methods=["GET"]) -@login_required -async def search_route(): - uid = get_current_user_id() - q = (request.args.get("q") or "").strip() - if not q: - return jsonify({"error": "q is required"}), 400 - - content_type = request.args.get("content_type", "all") - limit = min(request.args.get("limit", 10, type=int), 50) - is_task = _content_type_to_is_task(content_type) - - results = await semantic_search_notes( - uid, q, limit=limit, is_task=is_task, threshold=0.3 - ) - return jsonify({ - "results": [ - { - "id": note.id, - "title": note.title, - "body": note.body or "", - "is_task": note.is_task, - "tags": note.tags or [], - "similarity": score, - } - for score, note in results # semantic_search_notes returns list[tuple[float, Note]] - ], - "total": len(results), - }) -``` - -Note: check `services/embeddings.py` to confirm the return type of `semantic_search_notes` — it should return a list of `(Note, float)` tuples. If the signature differs, adjust the destructuring above accordingly. - -- [ ] **Step 4: Register in app.py** - -Add to imports in `src/scribe/app.py`: -```python -from scribe.routes.search import search_bp -``` - -Add registration: -```python -app.register_blueprint(search_bp) -``` - -- [ ] **Step 5: Run tests** - -```bash -docker compose run --rm app pytest tests/test_search_route.py tests/ -v -``` - -Expected: all tests pass - -- [ ] **Step 6: Commit** - -```bash -git add src/scribe/routes/search.py \ - src/scribe/app.py \ - tests/test_search_route.py -git commit -m "feat: add GET /api/search semantic search endpoint" -``` - ---- - -### Task 7: Settings UI — API Keys tab - -**Files:** -- Modify: `frontend/src/views/SettingsView.vue` - -- [ ] **Step 1: Add "apikeys" to VALID_TABS** - -In `SettingsView.vue`, find the `VALID_TABS` array (search for `VALID_TABS`) and add `"apikeys"` to it. - -- [ ] **Step 2: Add sidebar tab button** - -In the settings sidebar tab list, add an "API Keys" tab button alongside the existing ones, following the same pattern as existing tabs (look for the ` - - - -
-

Copy this key now — it will not be shown again.

-
- {{ newKeyValue }} - -
- -
- - -
- - - - - - - - - - - - - - - - - - - -
NameScopePrefixLast Used
{{ key.name }}{{ key.scope }}{{ key.key_prefix }}…{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }} - - - Sure? - - - -
-
-

No API keys yet.

- -``` - -- [ ] **Step 4: Add reactive state and methods to the script section** - -In the `') + assert remote_refs('') + assert not remote_refs("/* no @import url('https://x.example.com') here */") + assert not remote_refs("@font-face { src: url(./inter-normal-latin.woff2); }") + assert not remote_refs('docs') diff --git a/tests/test_frontend_request_deadlines.py b/tests/test_frontend_request_deadlines.py index 65abd0d4..87200075 100644 --- a/tests/test_frontend_request_deadlines.py +++ b/tests/test_frontend_request_deadlines.py @@ -1,4 +1,4 @@ -"""Every request the web UI makes has a deadline (rule 156). +"""Every request the web UI makes has a deadline. A source-inspection guard in the unit lane — there is no frontend test runner, and this is a property of the source rather than of a rendered result, so @@ -7,14 +7,13 @@ reading the source is the honest way to check it. WHY. `fetch`'s default is to wait as long as the browser will. That is not a long timeout, it is the absence of one, and there is no state a surface can render for "pending forever" that is not a lie — the spinner that never -resolves is indistinguishable from work still in progress. Rule 156 names +resolves is indistinguishable from work still in progress. The practice names `fetch` specifically: When a library's default is "wait indefinitely" — `fetch`, most HTTP clients, a bare `await` on a stream — supplying the deadline is part of using it, not a hardening pass for later. -Before this guard, no request in the app carried one. """ from __future__ import annotations @@ -67,17 +66,15 @@ def test_every_fetch_passes_a_signal(): if "signal:" not in call ] assert not naked, ( - "these fetch calls carry no AbortSignal, so they wait forever " - "(rule 156):\n " + "\n ".join(naked) + "these fetch calls carry no AbortSignal, so they wait forever:\n " + "\n ".join(naked) ) def test_the_client_applies_its_deadline_by_default(): """The specific regression that would silently undo this. - An earlier pass (#3329) made `timeoutMs` OPT-IN and used it at exactly one - call site, which left ~330 others waiting forever while the mechanism - looked present. Reverting to that shape would not fail the guard above — + An OPT-IN `timeoutMs` used at one call site leaves every other call + waiting forever while the mechanism looks present. That shape would not fail the guard above — every call would still reach `fetch` through `request()` — so the default is pinned here separately. @@ -93,7 +90,7 @@ def test_the_client_applies_its_deadline_by_default(): def test_a_timeout_arrives_as_the_error_shape_callers_already_handle(): - """Rule 156's second half: expiry surfaces as a NAMED failure. + """The deadline's second half: expiry surfaces as a NAMED failure. A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)` as an object with no `body`, so every catch site in the app would print its diff --git a/tests/test_gate_settings.py b/tests/test_gate_settings.py index 0ff4a6e9..7caca7ba 100644 --- a/tests/test_gate_settings.py +++ b/tests/test_gate_settings.py @@ -1,9 +1,9 @@ -"""The create gate's similarity bars are settings, clamped and fail-open (#4385).""" +"""The create gate's similarity bars are settings, clamped and fail-open.""" from unittest.mock import AsyncMock, patch import pytest -from scribe.services.dedup import ( +from grimoire.services.dedup import ( _GATE_MIN_BLOCK, _GATE_MIN_OVERLAP, GATE_DEFAULT_THRESHOLDS, @@ -16,7 +16,7 @@ _real_gate_setting = _gate_setting def _setting(value): - return patch("scribe.services.settings.get_setting", AsyncMock(return_value=value)) + return patch("grimoire.services.settings.get_setting", AsyncMock(return_value=value)) @pytest.mark.asyncio @@ -46,7 +46,7 @@ async def test_an_unparseable_value_falls_back_to_the_default(): @pytest.mark.asyncio async def test_an_unreadable_setting_falls_back_to_the_default(): - with patch("scribe.services.settings.get_setting", + with patch("grimoire.services.settings.get_setting", AsyncMock(side_effect=RuntimeError("db down"))): got = await _real_gate_setting(7, "lesson", _GATE_MIN_BLOCK) assert got == GATE_DEFAULT_THRESHOLDS["lesson"] @@ -54,18 +54,18 @@ async def test_an_unreadable_setting_falls_back_to_the_default(): @pytest.mark.asyncio async def test_the_overlap_floor_never_sits_above_the_block_bar(): - from scribe.services.dedup import gate_bars + from grimoire.services.dedup import gate_bars async def _set(user_id, key, lo): return {"note_copy": 0.85, "note_overlap": 0.90}[key] - with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)): + with patch("grimoire.services.dedup._gate_setting", AsyncMock(side_effect=_set)): assert await gate_bars(7, "note") == (0.85, 0.85) @pytest.mark.asyncio async def test_a_kind_outside_the_copy_band_reads_no_overlap_floor(): - from scribe.services.dedup import gate_bars + from grimoire.services.dedup import gate_bars seen = [] @@ -73,6 +73,6 @@ async def test_a_kind_outside_the_copy_band_reads_no_overlap_floor(): seen.append(key) return GATE_DEFAULT_THRESHOLDS[key] - with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)): + with patch("grimoire.services.dedup._gate_setting", AsyncMock(side_effect=_set)): assert await gate_bars(7, "process") == (0.90, 0.90) assert seen == ["general"] diff --git a/tests/test_scribe_marker.py b/tests/test_grimoire_marker.py similarity index 65% rename from tests/test_scribe_marker.py rename to tests/test_grimoire_marker.py index 1c40604f..a792cfce 100644 --- a/tests/test_scribe_marker.py +++ b/tests/test_grimoire_marker.py @@ -1,18 +1,17 @@ -"""`.scribe` — the second key a directory can be scoped by (#4085). +"""`.grimoire` — the second key a directory can be scoped by. -Every hook in `plugin/hooks/` scopes its request to a project, and until this -existed all six turned on one key: `git remote get-url origin`. That key does -not exist outside a git repo, so a session in a plain directory was unscoped -everywhere at once — silently, because a missing remote is indistinguishable +Every hook in `plugin/hooks/` scopes its request to a project. With only one +key, `git remote get-url origin`, a session outside a git repo has no key at +all, so a session in a plain directory is unscoped everywhere at once — silently, because a missing remote is indistinguishable from a remote nobody bound. -`scribe_scope_query` is the shared answer, so these run the real shell rather +`grimoire_scope_query` is the shared answer, so these run the real shell rather than a reimplementation of it. What they pin: * the marker is found from a subdirectory, the way git finds its root; * a bare integer works, because that is what a person writes by hand; * the marker BEATS a git remote, since someone put the file there on purpose; - * an `instance` naming a different Scribe is refused — the case the field + * an `instance` naming a different Grimoire is refused — the case the field exists for. A project id means nothing on its own: id 2 is a different project on every instance, so a marker that travels would otherwise scope the session to the wrong project, confidently and without a word. @@ -31,8 +30,8 @@ from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] -DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh" -INSTANCE = "https://scribe.example.com" +DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh" +INSTANCE = "https://grimoire.example.com" def _sh(script: str, cwd: Path, env_extra: dict | None = None) -> str: @@ -49,7 +48,7 @@ def _sh(script: str, cwd: Path, env_extra: dict | None = None) -> str: def _marker(d: Path, **fields) -> None: - (d / ".scribe").write_text(json.dumps(fields)) + (d / ".grimoire").write_text(json.dumps(fields)) def _git_repo(d: Path, remote: str) -> None: @@ -61,31 +60,31 @@ def _git_repo(d: Path, remote: str) -> None: def test_a_bare_integer_is_a_valid_marker(tmp_path): """What someone writes by hand. It parses as JSON already, so one filter reads it and the written form alike.""" - (tmp_path / ".scribe").write_text("7\n") - assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=7" + (tmp_path / ".grimoire").write_text("7\n") + assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "project_id=7" def test_the_marker_is_found_from_a_subdirectory(tmp_path): - _marker(tmp_path, instance=INSTANCE, project_id=2, project="FabledScribe") - deep = tmp_path / "src" / "scribe" / "services" + _marker(tmp_path, instance=INSTANCE, project_id=2, project="FabledGrimoire") + deep = tmp_path / "src" / "grimoire" / "services" deep.mkdir(parents=True) - assert _sh(f'scribe_scope_query "{deep}"', tmp_path) == "project_id=2" + assert _sh(f'grimoire_scope_query "{deep}"', tmp_path) == "project_id=2" def test_a_matching_instance_is_honoured(tmp_path): """Host-only comparison, so http/https and a trailing slash are not a - mismatch — only a genuinely different Scribe is.""" - _marker(tmp_path, instance="http://scribe.example.com/", project_id=2) - assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=2" + mismatch — only a genuinely different Grimoire is.""" + _marker(tmp_path, instance="http://grimoire.example.com/", project_id=2) + assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "project_id=2" def test_a_marker_for_another_instance_is_refused_with_a_reason(tmp_path): """The case the `instance` field exists for. No project beats the wrong project, and the reason is carried so the session can say which it was.""" _marker(tmp_path, instance="https://someone-elses.example.org", project_id=2) - assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "" - said = _sh(f'scribe_marker_read "{tmp_path}/.scribe"', tmp_path) - assert "someone-elses.example.org" in said and "scribe.example.com" in said + assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "" + said = _sh(f'grimoire_marker_read "{tmp_path}/.grimoire"', tmp_path) + assert "someone-elses.example.org" in said and "grimoire.example.com" in said def test_the_marker_beats_a_git_remote(tmp_path): @@ -93,21 +92,21 @@ def test_the_marker_beats_a_git_remote(tmp_path): code happens to be pushed. This is also how a directory overrides its binding.""" _git_repo(tmp_path, "git@git.example.com:someone/thing.git") - assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path).startswith("repo=") + assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path).startswith("repo=") _marker(tmp_path, instance=INSTANCE, project_id=2) - assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=2" + assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "project_id=2" def test_without_a_marker_the_git_remote_still_answers(tmp_path): """The path every existing install is on — it must not have moved.""" _git_repo(tmp_path, "git@git.example.com:someone/thing.git") - got = _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) + got = _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) assert got.startswith("repo=") and "git.example.com" in got.replace("%2F", "/") def test_a_plain_directory_with_no_marker_scopes_to_nothing(tmp_path): """Not an error — the caller sends no scope and the server says so.""" - assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "" + assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "" @pytest.mark.parametrize("body", ["", "not json at all", "{}", '{"project_id": 0}', @@ -115,18 +114,18 @@ def test_a_plain_directory_with_no_marker_scopes_to_nothing(tmp_path): def test_an_unusable_marker_is_refused_rather_than_sent(tmp_path, body): """A marker is operator-written and can say anything. Nothing that is not a positive integer may reach the query string.""" - (tmp_path / ".scribe").write_text(body) - assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "" + (tmp_path / ".grimoire").write_text(body) + assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "" def test_the_reason_survives_the_command_substitution_that_fetches_it(tmp_path): - """Regression on a bug this nearly shipped with. + """The reason cannot be a global the function sets. - The reason used to be a global the function set, which every caller reads - through `$( )` — a subshell, so the assignment died with it and the caller - saw an UNSET variable. The hooks all run `set -u`, where reading one aborts - the script: a directory with a misaddressed marker would have cost the - session its entire SessionStart context, to fetch a warning about a file. + Every caller reads the function through `$( )` — a subshell, so a global + assignment dies with it and the caller sees an UNSET variable. The hooks all + run `set -u`, where reading one aborts the script: a directory with a + misaddressed marker would cost the session its entire SessionStart context, + to fetch a warning about a file. So the reason comes back through stdout with the id, and this asserts on the shape the caller actually uses: read in a subshell, split, and used @@ -134,7 +133,7 @@ def test_the_reason_survives_the_command_substitution_that_fetches_it(tmp_path): """ _marker(tmp_path, instance="https://elsewhere.example.org", project_id=2) got = _sh( - f'read_out=$(scribe_marker_read "$(scribe_marker_file "{tmp_path}")")\n' + f'read_out=$(grimoire_marker_read "$(grimoire_marker_file "{tmp_path}")")\n' 'printf "id=[%s] why=[%s]" "${read_out%%$\'\\t\'*}" "${read_out#*$\'\\t\'}"', tmp_path, ) @@ -147,4 +146,4 @@ def test_the_walk_up_terminates_at_the_root(tmp_path): is several levels down from /, so this really does walk.""" deep = tmp_path / "a" / "b" / "c" deep.mkdir(parents=True) - assert _sh(f'scribe_scope_query "{deep}"', tmp_path) == "" + assert _sh(f'grimoire_scope_query "{deep}"', tmp_path) == "" diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py index 46ae84be..0e2d893f 100644 --- a/tests/test_guidance_ownership.py +++ b/tests/test_guidance_ownership.py @@ -1,17 +1,16 @@ """Every piece of agent guidance has exactly one owner that states it. -WHY THIS EXISTS (milestone 410, decision #4027) +WHY THIS EXISTS -Scribe's guidance to agents was written up to five times over: the MCP +Grimoire's guidance to agents reaches a session through five surfaces: the MCP `_INSTRUCTIONS`, the plugin's static session context, the live session context -the server builds, the `using-scribe` skill, and tool docstrings. An earlier -design (#2494) made that deliberate — insurance against any one surface failing -silently — and the copies drifted apart instead (#2497, #4022). +the server builds, the skills, and tool docstrings. A practice stated in full +on several of them drifts apart, copy by copy. -Decision #4027 replaced the redundancy with ownership: the server orients with -a short index, the skills hold the depth, tool docstrings hold each tool's -contract, and each client adapter holds only its own timing and conventions. -This module is the registry that decision is enforced from. +So each practice has one owner: the server orients with a short index, the +skills hold the depth, tool docstrings hold each tool's contract, and each +client adapter holds only its own timing and conventions. This module is the +registry that ownership is enforced from. WHAT IT PINS @@ -27,7 +26,7 @@ WHAT IT PINS surface every MCP client receives, so each reflex it indexes keeps its `index` markers there — a one-line pointer, not a copy. Only session-start practices carry `index`: judging, reporting and the missed-rule route fire - mid-work, and #4389 took them out of the index (the skills and the in-band + mid-work, so they stay out of the index (the skills and the in-band responses carry them at the moment they apply). WHAT IT CANNOT SEE @@ -65,7 +64,7 @@ def _live_session_context_source() -> str: Read as text rather than called: the function needs a database, and what this module checks is what the product SAYS, which is the literal strings. """ - src = (ROOT / "src/scribe/services/plugin_context.py").read_text() + src = (ROOT / "src/grimoire/services/plugin_context.py").read_text() start = src.index("async def build_session_context") nxt = re.search(r"\n(?:async def|def) ", src[start + 1:]) return src[start:start + 1 + nxt.start()] if nxt else src[start:] @@ -74,7 +73,7 @@ def _live_session_context_source() -> str: def _delivered_shapes() -> str: """Every reply shape as a session receives it in full. Rendered rather than read as source: the header a shape arrives under is guidance too.""" - from scribe.services import reply_shapes + from grimoire.services import reply_shapes return "\n\n".join(reply_shapes.render(s, full=True) for s in reply_shapes.SHAPES.values()) @@ -90,20 +89,20 @@ def delivered_surfaces() -> dict[str, str]: - `commands` — the Claude Code adapter's slash commands - `live` — the live session context the server builds - `shapes` — the reply shapes the server delivers at their moments, - in the full form a session first sees (milestone 500) + in the full form a session first sees """ - server = (ROOT / "src/scribe/mcp/server.py").read_text() + server = (ROOT / "src/grimoire/mcp/server.py").read_text() match = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S) assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal" surfaces = { "instructions": match.group(1), - "docstrings": "".join(p.read_text() for p in sorted((ROOT / "src/scribe/mcp/tools").glob("*.py"))), - "static": (ROOT / "plugin/hooks/scribe_static_context.md").read_text(), + "docstrings": "".join(p.read_text() for p in sorted((ROOT / "src/grimoire/mcp/tools").glob("*.py"))), + "static": (ROOT / "plugin/hooks/grimoire_static_context.md").read_text(), "commands": "".join(p.read_text() for p in sorted((ROOT / "plugin/commands").glob("*.md"))), "live": _live_session_context_source(), "shapes": _delivered_shapes(), } - # A skill is SKILL.md plus the reference files it links (#4398): one + # A skill is SKILL.md plus the reference files it links: one # owner, however many files it is split across. for skill in sorted((ROOT / "plugin/skills").glob("*/SKILL.md")): surfaces[f"skill:{skill.parent.name}"] = skill_text(skill.parent.name) @@ -123,11 +122,11 @@ class Topic(NamedTuple): shared_with: tuple[str, ...] = () # other surfaces allowed the statement, with a reason -U = "skill:using-scribe" +U = "skill:using-grimoire" TOPICS: tuple[Topic, ...] = ( - # ── the working reflexes — owned by the using-scribe skill ── - Topic("scribe is the system of record; keep one copy", U, ("one copy",), + # ── the working reflexes — owned by the using-grimoire skill ── + Topic("grimoire is the system of record; keep one copy", U, ("one copy",), "let any existing local memory shrink", index=("one copy",)), Topic("orient: enter the project, check repo bindings", U, ("enter_project", "list_repo_bindings"), "the milestones and open tasks worked on most recently", @@ -144,9 +143,8 @@ TOPICS: tuple[Topic, ...] = ( index=("lessons inform",), # Stated twice on purpose, at the two moments it is needed: the # skill while deciding what a record IS, and create_rule while a - # proposal is actually being written — which is where the - # mis-routing #3733 recorded happens, and the caller there has not - # necessarily read the skill. + # proposal is actually being written — which is where mis-routing + # happens, and the caller there has not necessarily read the skill. shared_with=("docstrings",)), Topic("a lesson grows each time it proves itself", U, ("update_lesson", "re-keyed", "grows each time"), @@ -182,7 +180,7 @@ TOPICS: tuple[Topic, ...] = ( Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",), "a retrieved rule outranks a default habit"), Topic("log on completion and on a problem", U, ("hit or discover a problem",), "hit or discover a problem"), - # Milestone 381 step 4: the half of a hand-off only the model can do. The + # The half of a hand-off only the model can do. The # release is mechanical (SessionEnd hook); saying what happened is not. Topic("hand off before the context stops existing", U, ("hand off", "claims it for this session"), @@ -196,8 +194,8 @@ TOPICS: tuple[Topic, ...] = ( Topic("where a new rule goes, and its trigger", U, ("create_project_rule", "when_to_apply"), "whichever home it gets"), Topic("a rule vs the other entities", U, ("standing instruction",), "first ask whether it's a rule at all"), - # Moments (milestone 458 step 8). Delivery at a moment and its in-session - # corrections are owned by using-scribe's moments.md; the index carries one + # Moments. Delivery at a moment and its in-session + # corrections are owned by using-grimoire's moments.md; the index carries one # clause, and a new rule's moments are asked where its home and trigger are. Topic("rules mounted on a moment arrive by lookup; correct a misfire in-session", U, ("list_moments", "map_action", "unmap_action", "judge_rule_moments"), @@ -212,9 +210,8 @@ TOPICS: tuple[Topic, ...] = ( Topic("a missed when is mounted or mapped, not reworded", U, ("update_rule(moments=",), "first ask whether it missed a when or a what"), - # Milestone 416 step 9: the tuning tools shipped in #4102/#4104 and were - # named on NO instruction surface — measured, `retrieval_tuning_history` - # returned zero events. Machinery with no route to it. + # The tuning tools need a route on an instruction surface; machinery + # nothing names goes unused. Topic("a missed rule is a trigger to fix before a floor to move", U, ("retrieval_telemetry", "tune_retrieval", "retrieval_surfaces"), "take it to the record first and the dial second"), @@ -222,9 +219,8 @@ TOPICS: tuple[Topic, ...] = ( ("what_might_apply",), "ask what already covers that moment"), Topic("reference notes update in place; dev-logs don't", U, ("reference note",), "state updates in place; chronicles don't"), - # Milestone 444: an operator's ruling was kept only as a quote in a work - # log, beside a session's reading of it that contradicted it, and three - # later sessions built on the reading. No index marker: both fire + # A ruling kept only as a quote in a work log sits beside a session's + # reading of it, and later sessions build on the reading. No index marker: both fire # mid-work — when the operator decides, and when code is read as intent. Topic("an operator's ruling lives on the System it governs", U, ("rulings", "update_system", "the operator decided it"), @@ -238,8 +234,8 @@ TOPICS: tuple[Topic, ...] = ( # ── process arcs — owned by their skills ── Topic("plan in a milestone, steps created together", "skill:writing-plans", ("start_planning", "{{ref:"), "a milestone earns its place when the work has an arc", index=("start_planning",)), - # Milestone 415: sessions opened a second plan beside the roadmap milestone - # that already covered the work, because nothing told them to look. + # Without this, a session opens a second plan beside the roadmap milestone + # that already covers the work. Topic("find the existing plan before making one", "skill:writing-plans", ('content_type="milestone"', "unplanned_milestones", "existing_milestone"), "when an active milestone already covers the work, the plan is that milestone", @@ -247,14 +243,14 @@ TOPICS: tuple[Topic, ...] = ( Topic("reuse recorded shapes; record at first build", "skill:reusing-code", ("create_snippet", "when_to_use", "first build", "second copy"), "prior art offered beside a write is not noise", index=("create_snippet",)), - # Milestone 439: the writer judges what it wrote, at the end of the turn, + # The writer judges what it wrote, at the end of the turn, # asked by the plugin's Stop hook — the moment the knowledge exists. Topic("judge what you wrote before the turn ends", "skill:reusing-code", ("before the turn ends", "classify_shapes", 'kind: "file"'), "you are the one who knows what the code you just wrote is"), Topic("report back where the work stands", "skill:reporting-back", ("reporting-back", "placement"), "take the placement from the record"), - # Milestone 500: the default shapes are server product, delivered at their + # The default shapes are server product, delivered at their # moments with the operator's preferences mounted beside them — so the # rule that a preference wins is stated where the shape arrives. Topic("a preference beside a delivered shape is what the operator asked for", "shapes", @@ -263,8 +259,8 @@ TOPICS: tuple[Topic, ...] = ( Topic("every reply leads with its conclusion and stays short", "shapes", ("conclusion first", "the shortest reply that carries the answer", "what can go"), "length is work handed back to the reader"), - # Milestone 409 step 7: the sections were being FILLED rather than chosen, - # so a reply could satisfy every heading and still be unreadable. These + # Sections FILLED rather than chosen let a reply satisfy every heading and + # still be unreadable. These # three are the discipline around the scaffold, not the scaffold itself. Topic("sections are chosen, not filled, and the reply is cut twice", "skill:reporting-back", ("not a form to complete", "what can go"), @@ -275,19 +271,18 @@ TOPICS: tuple[Topic, ...] = ( Topic("a settled decision is acted on, not re-opened", "skill:reporting-back", ("already made",), "reads as contradicting yourself rather than as being careful"), - # Milestone 444: a cap a past session chose rode inside an option as a - # "known cost", the operator approved the option, and the cap read as - # theirs from then on. + # A cap a past session chose, riding inside an option as a "known cost", + # reads as the operator's once they approve the option. Topic("an option says whose call each carried assumption was", "skill:reporting-back", ("past session chose this", "conflict"), "an unlabelled one gets approved as if it had been asked"), - # Milestone 409 step 8: `placement` rides a WRITE, so a task the reply only - # cites arrived with nothing vouching for it — which is how a step finished - # four days earlier was reported as the open one (#4154). + # `placement` rides a WRITE, so a task the reply only cites arrives with + # nothing vouching for it — a finished step can be reported as the open + # one. Topic("a record you only cite still gets read", "skill:reporting-back", ("next_step", "only mention"), "a record you only mention is a record to read"), - # Milestone 463 step 6: family canon — when an idea is evaluated, how a + # Family canon — when an idea is evaluated, how a # project answers one, what a reason is, and the conflict order's reflex. # The criteria, outcomes and grounds themselves are product text on the # tools that enforce them; the skill owns when and how. @@ -298,8 +293,8 @@ TOPICS: tuple[Topic, ...] = ( index=("family-canon",)), # ── per-tool contracts and in-band behaviour — owned by the server ── Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"), - # The agent is the judge (#4208). Two topics, not one, because they fire - # at different moments and say different things: using-scribe owns the + # The agent is the judge. Two topics, not one, because they fire + # at different moments and say different things: using-grimoire owns the # authority — what you may decide and that deciding means READING, not # thresholding — while reporting-back owns the shape a report takes when # the judgment is made. They are stated separately rather than shared so @@ -325,11 +320,11 @@ TOPICS: tuple[Topic, ...] = ( "belongs to another user", index=("shared:true",)), Topic("stored processes are followed as written", "docstrings", ("get_process",), "follow the returned body"), Topic("a project is never guessed", "docstrings", ("never guessing a project",), "never guessing a project"), - Topic("an unbound repo gets a bind hint", "live", ("bind_repo",), "isn't mapped to a scribe project"), + Topic("an unbound repo gets a bind hint", "live", ("bind_repo",), "isn't mapped to a grimoire project"), # ── the Claude Code adapter's own conventions ── Topic("compact at clean seams", "static", ("/compact",), "compact at clean seams"), - Topic("stored processes sync into local skills", "commands", ("scribe-proc-",), "regenerate the local skill stubs"), - Topic("say so when scribe's tools are unavailable", "static", ("tools are unavailable",), + Topic("stored processes sync into local skills", "commands", ("grimoire-proc-",), "regenerate the local skill stubs"), + Topic("say so when grimoire's tools are unavailable", "static", ("tools are unavailable",), "rather than silently falling back to local notes"), ) @@ -368,8 +363,8 @@ def test_every_topic_is_stated_by_its_owner(): gaps = owner_gaps(TOPICS, delivered_surfaces()) assert not gaps, ( "these topics are no longer stated in full by their owner:\n " - + "\n ".join(gaps) + "\nEvery guidance topic has one owner (decision " - "#4027). If the text moved on purpose, move the topic's owner here in the " + + "\n ".join(gaps) + "\nEvery guidance topic has one owner. " + "If the text moved on purpose, move the topic's owner here in the " "same commit; if it was reworded, update its markers and statement." ) @@ -378,7 +373,7 @@ def test_no_topic_is_stated_in_full_off_its_owner(): found = copies(TOPICS, delivered_surfaces()) assert not found, ( "guidance stated in full on a surface that doesn't own it:\n " - + "\n ".join(found) + "\nOne owner per topic (decision #4027): replace " + + "\n ".join(found) + "\nOne owner per topic: replace " "the copy with a one-line pointer to the owner. If both places genuinely " "need it at different moments, declare it in `shared_with` with the reason." ) @@ -405,7 +400,7 @@ def test_topic_keys_are_unique(): def test_the_ownership_guards_can_fail(): - """Rule 167: each guard is shown turning red once.""" + """Each guard is shown turning red once.""" surfaces = { "instructions": "use the widget", "docstrings": "the widget owner statement lives here", @@ -424,9 +419,9 @@ def test_the_ownership_guards_can_fail(): assert index_gaps((topic,), surfaces) == ["'widget': ['not in the index']"] -# ── The skills are client-neutral (milestone 410 step 2) ──────────────── +# ── The skills are client-neutral ─────────────────────────────────────── # -# Agent Skills is an open format read by dozens of clients (#4023), so the +# Agent Skills is an open format read by dozens of clients, so the # skills folder is the part of every client package that is shared verbatim. # Anything only one client understands belongs in that client's adapter — # for Claude Code, the plugin's static context, hooks and commands — or the @@ -435,7 +430,7 @@ def test_the_ownership_guards_can_fail(): # - "claude", "~/.claude", ".claude/" the client itself and its paths # - "claude.md", "memory.md", "auto-memory", "automemoryenabled" # Claude Code's local memory files/setting -# - "/compact", "/scribe:" Claude Code slash commands +# - "/compact", "/grimoire:" Claude Code slash commands # - "sessionstart", "userpromptsubmit", "pretooluse", "posttooluse" # Claude Code hook event names # - "write/edit" Claude Code's editor tool names @@ -444,7 +439,7 @@ def test_the_ownership_guards_can_fail(): # name, while "bash" in prose is just the shell. CLIENT_SPECIFIC = ( "claude", "~/.claude", ".claude/", "claude.md", "memory.md", "auto-memory", - "automemoryenabled", "/compact", "/scribe:", "sessionstart", "userpromptsubmit", + "automemoryenabled", "/compact", "/grimoire:", "sessionstart", "userpromptsubmit", "pretooluse", "posttooluse", "write/edit", "claude_plugin_root", ) CLIENT_TOOL_NAME = re.compile(r"\bBash\b") @@ -465,7 +460,7 @@ def test_the_skills_name_no_particular_client(): offenders = {path: hits for path, hits in offenders.items() if hits} assert not offenders, ( f"skills that name one client: {offenders}. Skills are shared by every " - f"client package (decision #4027); say the universal thing in the skill " + f"client package; say the universal thing in the skill " f"and put the client's own name for it in that client's adapter." ) diff --git a/tests/test_hook_duplicate_confirmation.py b/tests/test_hook_duplicate_confirmation.py index 1277c530..0a81fcdc 100644 --- a/tests/test_hook_duplicate_confirmation.py +++ b/tests/test_hook_duplicate_confirmation.py @@ -1,9 +1,9 @@ -"""The by-name duplicate arm confirms its grep hits against the real extractor (#4227). +"""The by-name duplicate arm confirms its grep hits against the real extractor. -#4222 fixed one end of this defect: the extractors that decide what a payload -DEFINES now blank comment and string spans before any line matcher runs. This -is the other end — `scribe_local_dups`, which decides which OTHER files already -define that name — and it was a plain `git grep`. +The extractors that decide what a payload DEFINES blank comment and string +spans before any line matcher runs. This is the other end — `grimoire_local_dups`, +which decides which OTHER files already define that name — and a plain +`git grep` cannot do that. A grep sees lines, not spans. The line @@ -13,18 +13,16 @@ is prose from a module docstring in this repo, and it matches the arm's pattern for `name=with`. So the arm would tell a writer their genuine symbol was already defined, and point at a docstring. -WHAT THE FIX COSTS, measured rather than guessed, over 141 payload files of -this repo: the arm removed 15 of 210 report lines, and every one was a string -literal, a comment, an import statement, or Vue's `const emit = defineEmits()` -boilerplate. No real definition was lost — where a name had both a real -definition and a phantom, the phantom was dropped and the definition kept. -Timing: mean 193ms → 222ms, worst 569ms → 572ms. The arm was already dominated -by its twelve `git grep` calls, so confirming costs about 15% rather than the -second a per-(name, file) shape would have cost. +WHAT CONFIRMATION COSTS, measured rather than guessed: it drops string +literals, comments, import statements and Vue's `const emit = defineEmits()` +boilerplate, and no real definition — where a name has both a real definition +and a phantom, the phantom goes and the definition stays. The arm is +dominated by its `git grep` calls, so confirming adds about 15% (a mean of +~30ms) rather than the second a per-(name, file) shape would cost. These cases are written against real git repositories rather than mocks, because what is being pinned is the interaction between `git grep`, `head` and -the extractor — which is where #4042 lived too. +the extractor — which is also where a `pipefail` + `head` interaction lives. """ from __future__ import annotations @@ -36,7 +34,7 @@ from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] -DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh" +DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh" @pytest.fixture @@ -64,7 +62,7 @@ def repo(tmp_path): def run(names: str, writing: str = "new.py") -> str: script = (f'set -uo pipefail\n. "{DEFS}"\n' - f'printf %s "$NAMES" | scribe_local_dups "{root}" {writing}\n') + f'printf %s "$NAMES" | grimoire_local_dups "{root}" {writing}\n') out = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env={**env, "NAMES": names}, timeout=120) assert out.returncode == 0, out.stderr @@ -77,7 +75,7 @@ def repo(tmp_path): def test_a_sentence_about_a_definition_is_not_a_definition(repo): - """The verify case from #4227: one real `def change`, one docstring that + """One real `def change`, one docstring that wraps onto a line beginning `function change`. The writer is told about the first file and not the second.""" build, run = repo @@ -101,14 +99,13 @@ def test_a_sentence_about_a_definition_is_not_a_definition(repo): assert "in 1 other file(s)" in out -def test_the_repos_own_prose_no_longer_defines_with(): - """Not a fixture — this repo's actual corpus, which is where the defect - was found. +def test_the_repos_own_prose_does_not_define_with(): + """Not a fixture — this repo's actual corpus, the realistic case. - Before the confirmation pass the arm named four files for `with`: - `…record type with no vector…`, `A class with no rules anywhere…` and two - more, all of them prose in docstrings and comments. It now names none, - and none is the correct answer: nothing here defines a symbol called + Without the confirmation pass the arm names files for `with` on prose in + docstrings and comments — `…record type with no vector…`, `A class with + no rules anywhere…`. With it the arm names none, and none is the correct + answer: nothing here defines a symbol called `with`, and in several of the languages in this tree it could not, because it is a keyword. @@ -120,7 +117,7 @@ def test_the_repos_own_prose_no_longer_defines_with(): def arm(name: str) -> str: script = (f'set -uo pipefail\n. "{DEFS}"\n' - f'printf "sym\\t{name}\\n" | scribe_local_dups "{ROOT}" nothing.py\n') + f'printf "sym\\t{name}\\n" | grimoire_local_dups "{ROOT}" nothing.py\n') out = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=180) assert out.returncode == 0, out.stderr @@ -162,20 +159,19 @@ def test_an_import_is_not_a_definition(repo): assert "card.vue" not in out, "an import site is not a definition site" -# ── the caps, which is where #4042 lived ───────────────────────────────────── +# ── the caps ───────────────────────────────────────────────────────────────── def test_the_candidate_cap_is_raised_above_the_display_cap(): """Confirmation REMOVES hits, so a cap applied before it runs lets phantom matches crowd real definitions out of the window — hits dropped before - anyone looked at them, which is #4042's bug in a new place. Asserted on - structure (rule 167) because the ordering is what matters, not the - numbers.""" + anyone looked at them. Asserted on structure because the ordering is what + matters, not the numbers.""" src = DEFS.read_text() - for const in ("_SCRIBE_DUP_CANDIDATES=", "_SCRIBE_DUP_SHOWN="): + for const in ("_GRIMOIRE_DUP_CANDIDATES=", "_GRIMOIRE_DUP_SHOWN="): assert const in src, f"{const.rstrip(chr(61))} is gone — the two caps were folded back together" - cands = int(src.split("_SCRIBE_DUP_CANDIDATES=")[1].split("\n")[0]) - shown = int(src.split("_SCRIBE_DUP_SHOWN=")[1].split("\n")[0]) + cands = int(src.split("_GRIMOIRE_DUP_CANDIDATES=")[1].split("\n")[0]) + shown = int(src.split("_GRIMOIRE_DUP_SHOWN=")[1].split("\n")[0]) assert cands > shown, ( f"candidates ({cands}) must exceed the display cap ({shown}), or " "confirmation can only ever shrink an already-truncated window" @@ -196,7 +192,7 @@ def test_phantoms_ahead_of_the_display_cap_do_not_hide_a_real_definition(repo): def test_the_early_exiting_head_still_keeps_its_own_output(repo): - """#4042, re-run through the new shape: under `pipefail` a `head` that + """Under `pipefail` a `head` that exits while git grep is still writing must not void the hits it printed. Every file here holds a REAL definition, so confirmation keeps them all and only the display cap applies.""" @@ -211,7 +207,8 @@ def test_the_early_exiting_head_still_keeps_its_own_output(repo): def test_a_selector_named_inside_a_css_comment_is_not_a_rule(repo): - """#2990's defect, on the lookup side rather than the extraction side.""" + """A selector inside a CSS comment, on the lookup side rather than the + extraction side.""" build, run = repo build({ "real.css": ".badge { color: red; }\n", diff --git a/tests/test_hook_json_reader.py b/tests/test_hook_json_reader.py index c5b09387..c26d055f 100644 --- a/tests/test_hook_json_reader.py +++ b/tests/test_hook_json_reader.py @@ -1,12 +1,10 @@ -"""JSON, read and written by the hooks without jq (#4107). +"""JSON, read and written by the hooks without jq. -WHAT THIS REPLACED, AND WHY IT NEEDS TESTING AT ALL. Every hook used to open -`command -v jq >/dev/null 2>&1 || exit 0`, so on a machine without jq the -operator got no session context, no rules, no prior art and no process sync, -and not one word saying why — `exit 0` is indistinguishable from "ran fine, -nothing to say". jq is absent by default on macOS, on the Debian/Ubuntu slim -images, on Alpine and in most CI containers. The parser that replaced it is -`plugin/hooks/scribe_json.awk`, written in POSIX awk, and a parser is exactly +WHY NOT jq, AND WHY THIS NEEDS TESTING AT ALL. jq is absent by default on +macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers, +and a hook that exits quietly without it gives the operator no session context, +no rules, no prior art and no process sync, with not one word saying why. The +parser is `plugin/hooks/grimoire_json.awk`, written in POSIX awk, and a parser is exactly the kind of thing that works on the six documents you tried it on. So the centre of this file is a DIFFERENTIAL against Python's `json`: the same @@ -16,7 +14,7 @@ is one the hooks can be handed an arbitrary event with. The rest pins the three jobs around it — writing the hook envelope, percent-encoding a URL, and bounding a turn in a transcript — and that each -guard here can fail (rule 167). +guard here can fail. """ from __future__ import annotations @@ -32,13 +30,13 @@ import pytest from tests.helpers import need_tools HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" -DEFS = HOOKS / "scribe_defs.sh" -PARSER = HOOKS / "scribe_json.awk" -TURN = HOOKS / "scribe_turn.awk" +DEFS = HOOKS / "grimoire_defs.sh" +PARSER = HOOKS / "grimoire_json.awk" +TURN = HOOKS / "grimoire_turn.awk" def sh(script: str, stdin: str = "") -> str: - """Run a snippet with scribe_defs.sh sourced, under the hooks' own flags. + """Run a snippet with grimoire_defs.sh sourced, under the hooks' own flags. BYTES IN, BYTES OUT, decoded here — NOT `text=True`. Text mode turns on universal newlines, which rewrites a carriage return in the output to a @@ -67,14 +65,14 @@ def flat(doc: str, mode: str = "whole") -> list[tuple[str, str, str]]: # The differential: this parser against Python's. def reference_flatten(value, path="", out=None): - """What scribe_json.awk is specified to emit, computed with `json`. + """What grimoire_json.awk is specified to emit, computed with `json`. A string's VALUE is its RAW escaped body — the parser deliberately does not decode, because a newline inside a value would break the line format the shell reads it back with. Every array also reports its LENGTH at `[#]`, including an empty one, and an empty object reports `{#}` 0 — that is what lets a caller tell "the server answered with zero notes" from "the server - did not answer", which #2932 built an outage marker around. + did not answer", which the outage marker is built around. """ out = [] if out is None else out if isinstance(value, dict): @@ -152,7 +150,7 @@ def test_a_value_survives_the_round_trip_the_hooks_actually_make(value): character entry matters most: it is longer than the parser's token window, so it takes the wide-read fallback rather than the fast path.""" doc = json.dumps({"tool_input": {"content": value}}, ensure_ascii=False) - got = sh('flat=$(scribe_json_flat); scribe_json_pick "$flat" \'.tool_input.content\'', + got = sh('flat=$(grimoire_json_flat); grimoire_json_pick "$flat" \'.tool_input.content\'', stdin=doc) assert got.rstrip("\n") == value.rstrip("\n") @@ -172,7 +170,7 @@ def test_an_escaped_character_decodes_to_utf8_in_any_locale(value, locale): if locale: env["LC_ALL"] = locale body = json.dumps(value)[1:-1] - r = subprocess.run(["bash", "-c", f'. "{DEFS}"; scribe_json_unescape'], + r = subprocess.run(["bash", "-c", f'. "{DEFS}"; grimoire_json_unescape'], input=body.encode(), capture_output=True, env=env) assert r.returncode == 0, r.stderr.decode() assert r.stdout == (value + "\n").encode() @@ -192,14 +190,14 @@ def test_pick_list_and_len_read_what_the_server_sends(): "sync_note_ids": [22], "derive_keys": ["k1", "k2"], "rule_ids": [], "project": {"id": 7}}) out = sh(r""" - flat=$(scribe_json_flat) - echo "ctx=$(scribe_json_pick "$flat" '.context')" - echo "pid=$(scribe_json_pick "$flat" '.project.id')" - echo "notes=$(scribe_json_list "$flat" '.note_ids' | tr '\n' ',')" - echo "keys=$(scribe_json_list "$flat" '.derive_keys' | tr '\n' ',')" - echo "n=$(scribe_json_len "$flat" '.note_ids')" - echo "rules_n=$(scribe_json_len "$flat" '.rule_ids')" - echo "minus=$(scribe_json_list_minus "$flat" '.note_ids' '.sync_note_ids' | tr '\n' ',')" + flat=$(grimoire_json_flat) + echo "ctx=$(grimoire_json_pick "$flat" '.context')" + echo "pid=$(grimoire_json_pick "$flat" '.project.id')" + echo "notes=$(grimoire_json_list "$flat" '.note_ids' | tr '\n' ',')" + echo "keys=$(grimoire_json_list "$flat" '.derive_keys' | tr '\n' ',')" + echo "n=$(grimoire_json_len "$flat" '.note_ids')" + echo "rules_n=$(grimoire_json_len "$flat" '.rule_ids')" + echo "minus=$(grimoire_json_list_minus "$flat" '.note_ids' '.sync_note_ids' | tr '\n' ',')" """, stdin=body) got = dict(line.split("=", 1) for line in out.splitlines()) assert got == {"ctx": "some markdown", "pid": "7", "notes": "11,22,33,", @@ -211,9 +209,9 @@ def test_an_absent_field_and_a_null_field_both_read_as_empty(): test `[ -n "$x" ]`. A field the server left null and one it never sent mean the same thing to all of them.""" out = sh(r""" - flat=$(scribe_json_flat) - printf '[%s][%s][%s]\n' "$(scribe_json_pick "$flat" '.nothing')" \ - "$(scribe_json_pick "$flat" '.nulled')" "$(scribe_json_pick "$flat" '.real')" + flat=$(grimoire_json_flat) + printf '[%s][%s][%s]\n' "$(grimoire_json_pick "$flat" '.nothing')" \ + "$(grimoire_json_pick "$flat" '.nulled')" "$(grimoire_json_pick "$flat" '.real')" """, stdin=json.dumps({"nulled": None, "real": "here"})) assert out.strip() == "[][][here]" @@ -221,10 +219,10 @@ def test_an_absent_field_and_a_null_field_both_read_as_empty(): def test_an_absent_array_length_is_empty_not_zero(): """NOT the same answer as 0, and the distinction is load-bearing: the record nudge fires when the server said "zero notes" and must not fire - when the server said nothing at all (#2932).""" - out = sh(r"""flat=$(scribe_json_flat) - printf '[%s][%s]\n' "$(scribe_json_len "$flat" '.note_ids')" \ - "$(scribe_json_len "$flat" '.missing')" """, + when the server said nothing at all.""" + out = sh(r"""flat=$(grimoire_json_flat) + printf '[%s][%s]\n' "$(grimoire_json_len "$flat" '.note_ids')" \ + "$(grimoire_json_len "$flat" '.missing')" """, stdin=json.dumps({"note_ids": []})) assert out.strip() == "[0][]" @@ -254,7 +252,7 @@ def test_the_hook_envelope_is_valid_json_carrying_the_exact_context(value): """The one JSON document these hooks WRITE. Five hooks emitted it through `jq -n --arg c`; getting the escaping wrong corrupts every injected context that contains a Windows path, a regex or a quoted word.""" - out = sh('scribe_json_out PreToolUse "$(cat)"', stdin=value) + out = sh('grimoire_json_out PreToolUse "$(cat)"', stdin=value) parsed = json.loads(out) assert parsed["hookSpecificOutput"]["hookEventName"] == "PreToolUse" assert parsed["hookSpecificOutput"]["additionalContext"] == value.rstrip("\n") @@ -267,15 +265,15 @@ def test_urlenc_percent_encodes_exactly_the_unreserved_set(value): an accented letter as one and mawk as two, and an encoder built on substr() would produce a different URL depending on which awk is installed. Percent-encoding is defined on BYTES.""" - got = sh("scribe_urlenc", stdin=value) + got = sh("grimoire_urlenc", stdin=value) assert got == quote(value, safe="") def test_urlenc_and_the_envelope_can_fail(): - """Rule 167 — a guard that cannot fail is decoration. If the escaper were a + """A guard that cannot fail is decoration. If the escaper were a no-op, this pair would be the assertion that noticed.""" - assert sh("scribe_urlenc", stdin="a b") != "a b" - raw = sh('scribe_json_out PreToolUse "$(cat)"', stdin='he said "hi"') + assert sh("grimoire_urlenc", stdin="a b") != "a b" + raw = sh('grimoire_json_out PreToolUse "$(cat)"', stdin='he said "hi"') assert '\\"hi\\"' in raw, raw @@ -362,7 +360,7 @@ def test_the_reply_is_the_text_after_the_last_action_and_keeps_its_newlines(): facts = _turn([_prompt(), _text("thinking out loud"), _close("t1", 41), _text("line one\nline two"), _text("line three")]) assert facts["reply"] == "line one\\nline two\\nline three" - decoded = sh("scribe_json_unescape", stdin=facts["reply"]) + decoded = sh("grimoire_json_unescape", stdin=facts["reply"]) assert decoded == "line one\nline two\nline three\n" assert "thinking out loud" not in decoded @@ -376,7 +374,7 @@ def test_a_tool_result_does_not_read_as_a_prompt(): def test_the_turn_guards_can_fail(): - """Rule 167. If the analyzer counted everything, or nothing, these are the + """If the analyzer counted everything, or nothing, these are the assertions that would notice.""" assert _turn([_prompt(), _close("t1", 41), _text("r")])["closed"] == "1" assert _turn([_prompt(), _text("r")])["closed"] == "0" diff --git a/tests/test_hook_pipefail_head.py b/tests/test_hook_pipefail_head.py index 272778cf..7170fa02 100644 --- a/tests/test_hook_pipefail_head.py +++ b/tests/test_hook_pipefail_head.py @@ -1,14 +1,14 @@ -"""Under `pipefail`, an early-exiting `head` must not void the output it kept (#4042). +"""Under `pipefail`, an early-exiting `head` must not void the output it kept. The hooks run with `set -uo pipefail`. `hits=$(git grep -l … | head -4) || -hits=""` lost its four hits whenever git grep was still writing when head -exited: SIGPIPE, a failed substitution, and the fallback wiped the result. It -only showed on big outputs, so the by-name duplicate arm went silent for +hits=""` loses its four hits whenever git grep is still writing when head +exits: SIGPIPE, a failed substitution, and the fallback wipes the result. It +only shows on big outputs, so the by-name duplicate arm goes silent for exactly the most-duplicated names. Two halves: the real function on a repo big enough to overflow the pipe, and the check_plugin pattern that keeps the shape out of every hook (shown able to -fail, rule #167). +fail). """ from __future__ import annotations @@ -20,7 +20,7 @@ from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] -DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh" +DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh" def test_the_duplicate_arm_still_names_a_name_defined_in_thousands_of_files(tmp_path): @@ -40,7 +40,7 @@ def test_the_duplicate_arm_still_names_a_name_defined_in_thousands_of_files(tmp_ subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env) subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True, env=env) - script = f'set -uo pipefail\n. "{DEFS}"\nprintf "sym\\tslug\\n" | scribe_local_dups "{repo}" new.py\n' + script = f'set -uo pipefail\n. "{DEFS}"\nprintf "sym\\tslug\\n" | grimoire_local_dups "{repo}" new.py\n' out = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env, timeout=60) assert out.returncode == 0, out.stderr assert "`slug` is already defined in 4 other file(s)" in out.stdout diff --git a/tests/test_inception.py b/tests/test_inception.py index c85dc43a..a4d75f6d 100644 --- a/tests/test_inception.py +++ b/tests/test_inception.py @@ -1,14 +1,13 @@ -"""Project inception (milestone 297) — step 1: the record's shape. +"""Project inception — the record's shape. The WHY a project inherits what it does lives on projects.inception. Pure -validation is pinned here; the effects are step 3's integration tests. +validation is pinned here; the effects are the integration tests. -Rules left inception in two steps: the always-on opt-out with the tier -(milestone 394), and the rulebook subscription with subscriptions (milestone -414). A rule in a rulebook is global, so there is nothing to choose. +Rules are not an inception choice: a rule in a rulebook is global, so there is +nothing to choose. """ -from scribe.models.project import Project -from scribe.services.inception import ( +from grimoire.models.project import Project +from grimoire.services.inception import ( CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception, ) @@ -39,7 +38,7 @@ def test_validate_inception_pins_the_choice_vocabulary(): # believe a rulebook had been declined. assert "unknown inception choice" in validate_inception( {"exclude_always_on_rulebooks": [1]}) - # Same for the subscription choice since milestone 414. + # Same for a rulebook subscription choice. assert "unknown inception choice" in validate_inception({"subscribe_rulebooks": [2]}) assert "positive id or null" in validate_inception({"design_system_id": 0}) assert "positive id or null" in validate_inception({"design_system_id": True}) @@ -71,13 +70,13 @@ def test_normalize_choices_is_canonical_and_complete(): def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant(): - """The vocabulary moved from a module constant to the global catalog table - (milestone 307): a constant cannot be a foreign key, so nothing outside a + """The vocabulary is the global catalog table, not a module constant: a + constant cannot be a foreign key, so nothing outside a project could reference an area. The seed and the bootstrap ask must both read the table, or the list they show and the list they mint diverge.""" import inspect - from scribe.services import systems as systems_svc + from grimoire.services import systems as systems_svc assert not hasattr(systems_svc, "STANDARD_SYSTEMS"), ( "the constant is gone — the catalog table is the single source" diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index 47ab15f2..83b6b83f 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -1,34 +1,16 @@ """What the instruction surfaces must say that one owner per topic cannot enforce. -WHY THIS FILE IS SMALLER THAN IT WAS - -Until milestone 410 this file pinned REDUNDANCY: every session-start surface — -the MCP `_INSTRUCTIONS`, the plugin's static context, the `using-scribe` skill — -had to restate how a rule reaches a session, what an empty session means, the -Systems reflex and the snippet-recording triggers. That was the design #2494 -chose after #2497 (two surfaces disagreeing about who loads the rules) and -#2198 (every hook silently inert): say it everywhere, so no single failure -loses it. - -Decision #4027 replaced that with ownership, and tests/test_guidance_ownership.py -now enforces it: every topic stated in full by exactly one owner, with a -one-line pointer in the server's index. The tests that required every surface -to repeat itself were retired there, because they enforced the very duplication -that drifted (#4022). What they protected is still protected: - - "state how to ask for a rule" / "say an empty session is not an empty - rulebook" → the rules topic: owned by using-scribe, indexed in - `_INSTRUCTIONS` with both phrases; - - the Systems reflex and the snippet-recording triggers → their topics' - markers, required on their owners; - - "never name the SessionStart push without stating the ask" (#2497's exact - shape) → retired outright. Since milestone 394 the push carries no rules, - so naming it can no longer imply rules were handled, and requiring every - surface that mentions it to restate the ask would force a copy. +tests/test_guidance_ownership.py enforces who states each topic: every topic +stated in full by exactly one owner, with a one-line pointer in the server's +index. How to ask for a rule and what an empty session means are the rules +topic, owned by using-grimoire and indexed in `_INSTRUCTIONS`; the Systems +reflex and the snippet-recording triggers are their topics' markers, required +on their owners. WHAT STAYS HERE Properties of a CLAIM rather than of who owns a topic: the index fits the -fold; a surface that says rules bind also names what does not; using-scribe +fold; a surface that says rules bind also names what does not; using-grimoire still sends a session to the reporting-back skill. """ from __future__ import annotations @@ -46,14 +28,14 @@ def _all_surfaces() -> list[tuple[str, str]]: what the hook does is not the same act as telling an agent it need not pull. The boundary is "does a session read this", which is skills (loaded by description match, and each reference file a skill links is read on its - own, so it is its own surface — #4398), the hook-injected static context, and the MCP server's + own, so it is its own surface), the hook-injected static context, and the MCP server's own instructions. """ found = [(str(p.relative_to(ROOT)), p.read_text()) for p in (ROOT / "plugin" / "skills").rglob("*.md")] found += [(str(p.relative_to(ROOT)), p.read_text()) for p in (ROOT / "plugin" / "hooks").glob("*.md")] - server = ROOT / "src" / "scribe" / "mcp" / "server.py" + server = ROOT / "src" / "grimoire" / "mcp" / "server.py" found.append((str(server.relative_to(ROOT)), server.read_text())) return found @@ -61,17 +43,16 @@ def _all_surfaces() -> list[tuple[str, str]]: def _instructions_text() -> str: """The _INSTRUCTIONS literal from server.py, as the client would see it.""" import re - src = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text() + src = (ROOT / "src" / "grimoire" / "mcp" / "server.py").read_text() match = re.search(r'_INSTRUCTIONS = """(.*?)"""', src, re.S) assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal" return match.group(1).strip() # Claude Code injects only the first ~2,048 characters of an MCP server's -# instructions and silently cuts the rest mid-word (#2562: observed live — -# the previous 20k-char version delivered ~10% of itself, and none of the -# Systems tagging guidance ever reached a session). The budget is set below -# the cap, not at it: the field runs ~600–1,600 characters (spike #4389), and +# instructions and silently cuts the rest mid-word, so guidance past the cap +# never reaches a session. The budget is set below the cap, not at it: the +# field runs ~600–1,600 characters, and # a block pressed against 2,048 is being used for depth that belongs in a # skill. INSTRUCTIONS_BUDGET = 1600 @@ -81,18 +62,17 @@ def test_instructions_fit_the_fold(): text = _instructions_text() assert len(text) <= INSTRUCTIONS_BUDGET, ( f"_INSTRUCTIONS is {len(text)} chars; Claude Code injects only ~2,048 " - f"and silently cuts the rest (#2562). This block is an INDEX — state " - f"the topic in full on its owner (a skill or the tool's docstring, " - f"decision #4027) and give it at most a line here; see the comment " + f"and silently cuts the rest. This block is an INDEX — state " + f"the topic in full on its owner (a skill or the tool's docstring) " + f"and give it at most a line here; see the comment " f"above _INSTRUCTIONS." ) # ── force: a surface that says rules bind must say what does not ──────── # -# Added with the preference kind (milestone 399). Before it, "rules bind" was -# the whole truth and every surface said so flatly. It is now half of one, and -# the half that is missing is the dangerous half to omit: a session reading +# With a preference kind beside rules, "rules bind" is half of the truth, and +# the missing half is the dangerous half to omit: a session reading # only "rules bind" and then receiving a preference has been told, by the most # authoritative surface it has, to treat it as binding. # @@ -107,7 +87,7 @@ def test_instructions_fit_the_fold(): # PINNED ON THE CLAIM, NOT THE WORD "bind". A bare substring also matches # `bind_repo`, `list_repo_bindings` and the DNS-rebinding comment in # server.py — so it would one day fail a skill that mentions repo binding and -# has nothing to do with force, which is rule 167's named failure: a guard +# has nothing to do with force — a guard # raising a false alarm about the very thing it protects. These phrases are # the ones that actually assert bindingness to a reader. BINDING_CLAIMS = ( @@ -144,7 +124,7 @@ def test_a_surface_claiming_rules_bind_also_names_what_does_not(): # whole corpus; a session that meets a third line shape with no name for it # falls back on the nearest one it has, which is the binding one. # -# The currency half is the claim #3896 was written around: a preference is the +# The currency half is the central claim: a preference is the # one record a session updates ITSELF, without asking. Stated only in the # skill, it reaches a session that loaded the skill — which is exactly the # session least likely to need telling. In the index it reaches everyone. @@ -168,12 +148,12 @@ def test_the_index_names_all_three_strengths(): f"client receives, and a record kind it does not name is one the session " f"has to guess the force of — which it does by reaching for the kind it " f"does know. Keep it to a clause; the full statement lives on " - f"using-scribe (decision #4027)." + f"using-grimoire." ) def test_the_strength_guard_can_fail(): - """Rule 167. The guard has to be able to bite, and it bites on omission — + """The guard has to be able to bite, and it bites on omission — the failure that actually happens, when a kind is added to the product and the index is left describing the corpus that came before it.""" text = "rules bind; preferences guide and you keep them current." @@ -182,22 +162,21 @@ def test_the_strength_guard_can_fail(): assert missing == ["that lessons inform"] -# ── Reporting back (milestone 409 step 3) ────────────────────────────── +# ── Reporting back ───────────────────────────────────────────────────── # # The reporting-back skill carries the shapes, but a skill only helps if it -# fires. The reflex that sends a session to it is stated in using-scribe, and +# fires. The reflex that sends a session to it is stated in using-grimoire, and # the in-band cue on update_task (`placement`, `report_back`) is the half that -# fires on its own in every client. The server's index carried a REPORT line -# until #4389: reporting is a mid-work stance, not a session-start practice. The -# static context was a second plugin-side copy until milestone 410. +# fires on its own in every client. The server's index carries no REPORT line: +# reporting is a mid-work stance, not a session-start practice. REPORT_REFLEX = "report back in a shape the operator can read" REPORT_SURFACES = ( - ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md", + ROOT / "plugin" / "skills" / "using-grimoire" / "SKILL.md", ) -def test_the_reporting_reflex_is_stated_in_using_scribe(): +def test_the_reporting_reflex_is_stated_in_using_grimoire(): missing = [] for path in REPORT_SURFACES: text = " ".join(path.read_text().split()).lower() @@ -211,8 +190,8 @@ def test_the_reporting_reflex_is_stated_in_using_scribe(): def test_the_reporting_reflex_takes_placement_from_the_record(): - """The failure milestone 409 began from: a placement reconstructed from - memory reads exactly like a real one when it is wrong.""" + """A placement reconstructed from memory reads exactly like a real one + when it is wrong.""" for path in REPORT_SURFACES: text = " ".join(path.read_text().split()) assert "`placement`" in text, f"{path.relative_to(ROOT)} no longer points at the placement block" diff --git a/tests/test_integration_backup_code_shape_roundtrip.py b/tests/test_integration_backup_code_shape_roundtrip.py index 57b9244f..01f41afd 100644 --- a/tests/test_integration_backup_code_shape_roundtrip.py +++ b/tests/test_integration_backup_code_shape_roundtrip.py @@ -1,11 +1,11 @@ -"""Real-Postgres round trip for code_shapes (#4197). +"""Real-Postgres round trip for code_shapes. -**What this guards is a column that was simply not there.** `code_shapes` -exported `reason_code`, `recheck_at` and `diverges_from` and imported none of -them — a restore reported success and came back with every judgment's verdict +**What this guards is a column the import leaves out.** If `code_shapes` +exports `reason_code`, `recheck_at` and `diverges_from` and imports none of +them, a restore reports success and comes back with every judgment's verdict and no code for why, every recheck flag cleared, and every divergence pointer -gone. The unit-level column guard in `test_services_backup.py` is what found -it and is what stops it recurring; this file is the behavioural half, because +gone. The unit-level column guard in `test_services_backup.py` catches a +column missing from either side; this file is the behavioural half, because "the builder passes the kwarg" and "the value survives a real restore" are different claims. @@ -23,12 +23,12 @@ import pytest_asyncio from datetime import datetime, timezone from sqlalchemy import select -from scribe.models import async_session -from scribe.models.code_shape import CodeShape -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.user import User -from scribe.services import backup +from grimoire.models import async_session +from grimoire.models.code_shape import CodeShape +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.user import User +from grimoire.services import backup from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] diff --git a/tests/test_integration_backup_file_ordering.py b/tests/test_integration_backup_file_ordering.py index 27f6833d..d847778e 100644 --- a/tests/test_integration_backup_file_ordering.py +++ b/tests/test_integration_backup_file_ordering.py @@ -1,11 +1,11 @@ """Real-Postgres check that the full export is ORDERED, so the backup file -deduplicates between runs (milestone 523). +deduplicates between runs. Only Postgres shows this. An unordered select returns rows in heap order, and -an UPDATE writes the row's new version at the end of the heap — so before -export_full_backup ordered its queries, editing one old note moved it to the -bottom of `notes`. Every byte after its old place shifted, and a restic run -over the next backup stored most of the table again. A mocked session returns +an UPDATE writes the row's new version at the end of the heap — so without an +ORDER BY in export_full_backup, editing one old note moves it to the bottom of +`notes`. Every byte after its old place shifts, and a restic run over the next +backup stores most of the table again. A mocked session returns whatever order it was handed and could never catch it. """ import io @@ -15,11 +15,11 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.note import Note -from scribe.models.user import User -from scribe.services import backup -from scribe.services.backup_file import write_archive_to +from grimoire.models import async_session +from grimoire.models.note import Note +from grimoire.models.user import User +from grimoire.services import backup +from grimoire.services.backup_file import write_archive_to from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] diff --git a/tests/test_integration_backup_file_restore.py b/tests/test_integration_backup_file_restore.py index 13ce1414..9b268ba5 100644 --- a/tests/test_integration_backup_file_restore.py +++ b/tests/test_integration_backup_file_restore.py @@ -1,6 +1,6 @@ -"""Real-Postgres proof that the scheduled backup's FILE restores (milestone -523): a payload written as the tar, read back through `scribe.restore`'s -loader, and fed to the real `restore_full_backup`. +"""Real-Postgres proof that the scheduled backup's FILE restores: a payload +written as the tar, read back through `grimoire.restore`'s loader, and fed to +the real `restore_full_backup`. The unit lane already shows the tar hands back the dict it was given. What only this lane shows is that the dict it hands back is one the restore @@ -11,15 +11,14 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.milestone import Milestone -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.user import User -from scribe.models.user_profile import UserProfile -from scribe.restore import load_backup, run -from scribe.services import backup -from scribe.services.backup_file import write_archive +from grimoire.models import async_session +from grimoire.models.milestone import Milestone +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.user import User +from grimoire.restore import load_backup, run +from grimoire.services import backup +from grimoire.services.backup_file import write_archive from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -52,7 +51,8 @@ async def _no_leftovers(): async def written(tmp_path): """A project with a milestone and a task in it, exported with the real row builders, renamed so the restore mints a separate user, and written - as the backup tar.""" + as the backup tar. It also carries a `user_profiles` section, as an + archive from before v27 does.""" async with async_session() as s: user = await ensure_user(s, OWNER) project = Project(user_id=user.id, title="Restore drill") @@ -65,10 +65,6 @@ async def written(tmp_path): title="Prove the restore", body="From the tar, not the JSON.", status="todo", note_type="note", task_kind="work") s.add(note) - profile = UserProfile(user_id=user.id, display_name="Drill Owner", - tone="technical", interests=["backups"], - work_schedule={"start": "09:00"}) - s.add(profile) await s.commit() payload = { "version": backup.BACKUP_VERSION, @@ -77,7 +73,8 @@ async def written(tmp_path): "projects": backup._project_rows([project]), "milestones": backup._milestone_rows([milestone]), "notes": backup._note_rows([note]), - "user_profiles": backup._user_profile_rows([profile]), + "user_profiles": [{"user_id": user.id, "display_name": "Drill Owner", + "tone": "technical", "interests": ["backups"]}], } payload["users"][0]["username"] = RESTORED from datetime import datetime, timezone @@ -94,19 +91,10 @@ async def test_the_tar_restores_with_every_id_remapped(written): project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one() milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one() note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one() - profile = (await s.execute( - select(UserProfile).where(UserProfile.user_id == user.id) - )).scalars().one() assert project.title == "Restore drill" assert (note.project_id, note.milestone_id) == (project.id, milestone.id) assert note.body == "From the tar, not the JSON." assert note.status == "todo" - # The Profile page travels with its user (#5879); a field left blank stays - # blank rather than restoring as the page's default. - assert (profile.display_name, profile.tone) == ("Drill Owner", "technical") - assert profile.interests == ["backups"] - assert profile.work_schedule == {"start": "09:00"} - assert profile.job_title is None async def test_the_command_refuses_a_database_with_accounts(written): diff --git a/tests/test_integration_backup_note_roundtrip.py b/tests/test_integration_backup_note_roundtrip.py index f6210fc4..68a0fbce 100644 --- a/tests/test_integration_backup_note_roundtrip.py +++ b/tests/test_integration_backup_note_roundtrip.py @@ -1,4 +1,4 @@ -"""Real-Postgres round trip for the note fields #3182 restored. +"""Real-Postgres round trip for the note fields a restore carries. The unit lane can prove a serialiser EMITS a key. It cannot prove a restore puts the value back on the right row, and the note->note edges are exactly @@ -7,8 +7,6 @@ the SOURCE database, so a restore that writes them straight through succeeds, reports success, and silently points every edge at whatever record happens to hold that number here. -That is why #3182 was not fixed in passing. - These drive the REAL `restore_full_backup`, not a reimplementation of its loop. A test that re-derives the remap it is checking would agree with whatever the product does, including nothing. @@ -17,10 +15,10 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.note import Note -from scribe.models.user import User -from scribe.services import backup +from grimoire.models import async_session +from grimoire.models.note import Note +from grimoire.models.user import User +from grimoire.services import backup from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -31,7 +29,7 @@ RESTORED_USERNAME = "backup_roundtrip_restored" @pytest_asyncio.fixture async def source(): """A snippet, a process, and an issue that arose from a work task — the - shapes whose identity the backup used to drop — exported as backup rows. + shapes whose identity a backup can drop — exported as backup rows. Deleted children-first: `arose_from_id` is a real FK, so removing the origin while the issue still points at it is asking the database a @@ -162,10 +160,10 @@ async def _no_leftover_restored_user(): async def test_a_restored_record_keeps_what_it_IS(restored): - """#3182's headline. Without note_type and task_kind a restore reported - success and handed back a corpus where every snippet and process was a - plain note and every issue and spike was `work` — the whole vocabulary - milestone 312 and #3128 were about, gone, with nothing to notice it by.""" + """Without note_type and task_kind a restore reports success and hands + back a corpus where every snippet and process is a plain note and every + issue and spike is `work` — the record vocabulary gone, with nothing to + notice it by.""" by_title, _ = restored assert by_title["debounce — rate-limit"].note_type == "snippet" diff --git a/tests/test_integration_backup_rule_usage_roundtrip.py b/tests/test_integration_backup_rule_usage_roundtrip.py index 5e8f0dc0..bc25ab40 100644 --- a/tests/test_integration_backup_rule_usage_roundtrip.py +++ b/tests/test_integration_backup_rule_usage_roundtrip.py @@ -1,9 +1,9 @@ -"""Real-Postgres round trip for rule_usage_events (milestone 333 step 1). +"""Real-Postgres round trip for rule_usage_events. **This file is the reason the table exists.** `rule_usage_events` could have been a `rule_id` column on `note_usage_events` — the row carries no note-specific field and the readout is the same shape, which is the strongest -case for sharing that note #3163 admits. What decided against it is identity at +case for sharing. What decides against it is identity at restore, and that is a claim only a real round trip can support. The failure it guards is the quiet kind. `note_usage_events`'s importer maps @@ -17,20 +17,20 @@ So the assertions below are about WHICH MAP resolved the id, and they are written to fail if the answer ever becomes "the note one" or "neither". Same shape as `test_integration_backup_rule_version_roundtrip.py`, which guards -`rule_versions` against #3182's `arose_from_id` trap on the same seam. +`rule_versions` against the same id-remapping trap on the same seam. """ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.note import Note -from scribe.models.rule_usage import ( +from grimoire.models import async_session +from grimoire.models.note import Note +from grimoire.models.rule_usage import ( APPLIED, DEPARTED, PULLED, SURFACED, RuleUsageEvent, ) -from scribe.models.rulebook import Rule, Rulebook, RulebookTopic -from scribe.models.user import User -from scribe.services import backup +from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic +from grimoire.models.user import User +from grimoire.services import backup from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -143,7 +143,7 @@ async def source(): user_id=None, rule_id=rule.id, event=SURFACED, source="write_path_rule", ), - # A departure and its reason (#4212). Seeded here because + # A departure and its reason. Seeded here because # `detail` is the ONE field on this table that cannot be # recomputed: a fresh install re-earns its counts by being used, # but a stated reason exists once and is gone if a restore drops @@ -290,7 +290,7 @@ async def test_the_actor_is_remapped_and_a_missing_one_survives(restored): attributed = [e for e in restored["events"] if e.user_id is not None] orphaned = [e for e in restored["events"] if e.user_id is None] # Four attributed: the surfacing, the pull, and the two outcome rows - # added with `detail` (#4212). One orphaned, deliberately. + # that carry `detail`. One orphaned, deliberately. assert len(attributed) == 4 assert len(orphaned) == 1, ( "the event with no actor did not come back. Telemetry outlives the " @@ -325,10 +325,9 @@ async def test_a_departures_reason_survives_the_round_trip(restored): 419 was opened to create, undone silently at the one moment nobody is watching. - #4197 is the standing warning behind this test: the backup column guard - watches the export side only, so a column added to the model and to the - exporter and NOT to the importer round-trips as null with nothing to say - so. + A column added to the model and to the exporter and NOT to the importer + round-trips as null with nothing to say so; this round trip is where that + shows up in the data. """ departures = [e for e in restored["events"] if e.event == DEPARTED] assert len(departures) == 1 diff --git a/tests/test_integration_backup_rule_version_roundtrip.py b/tests/test_integration_backup_rule_version_roundtrip.py index 5c27ff90..54f68b44 100644 --- a/tests/test_integration_backup_rule_version_roundtrip.py +++ b/tests/test_integration_backup_rule_version_roundtrip.py @@ -1,4 +1,4 @@ -"""Real-Postgres round trip for rule_versions (milestone 323 step 1). +"""Real-Postgres round trip for rule_versions. Two things here cannot be shown with mocks, and both are the kind that fail QUIETLY — a restore reports success and hands back history that is wrong. @@ -7,7 +7,7 @@ QUIETLY — a restore reports success and hands back history that is wrong. A restore that writes it straight through succeeds and attaches every snapshot to whatever rule happens to hold that number here — an edit history filed against the wrong binding instruction, which is worse than - no history at all. This is #3182's `arose_from_id` trap on a new table. + no history at all. It is the same trap as a note's `arose_from_id`. 2. **A null actor does not drop the row.** `user_id` is SET NULL precisely so history outlives the account that wrote it, so the restore deliberately @@ -23,11 +23,11 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.rule_version import RuleVersion -from scribe.models.rulebook import Rule, Rulebook, RulebookTopic -from scribe.models.user import User -from scribe.services import backup +from grimoire.models import async_session +from grimoire.models.rule_version import RuleVersion +from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic +from grimoire.models.user import User +from grimoire.services import backup from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -217,7 +217,7 @@ async def test_both_snapshots_come_back(restored): async def test_the_history_attaches_to_the_RESTORED_rule(restored): - """#3182's trap. The source rule still exists and holds a different id, so + """The remap trap. The source rule still exists and holds a different id, so a straight-through copy would file this history against it — or against whatever unrelated rule owns that number.""" new_rule_id = restored["rule"].id diff --git a/tests/test_integration_db_maintenance.py b/tests/test_integration_db_maintenance.py index e85f5d8e..01909599 100644 --- a/tests/test_integration_db_maintenance.py +++ b/tests/test_integration_db_maintenance.py @@ -8,7 +8,7 @@ AttributeError, reporting 0/6) passes the unit suite but fails here. """ import pytest -from scribe.services.db_maintenance import ( +from grimoire.services.db_maintenance import ( MAINTENANCE_TABLES, get_table_health, run_maintenance, diff --git a/tests/test_integration_embedding_yields_to_delete.py b/tests/test_integration_embedding_yields_to_delete.py index 265b44e8..e18edf6d 100644 --- a/tests/test_integration_embedding_yields_to_delete.py +++ b/tests/test_integration_embedding_yields_to_delete.py @@ -1,17 +1,17 @@ -"""#3262 against a real Postgres: the embedder loses the race, it doesn't run it. +"""Against a real Postgres: the embedder loses the race, it doesn't run it. -The reported failure was a deadlock — `DELETE FROM rulebooks` killed by the -server while a detached `upsert_rule_embedding` held the other half of the -cycle. It cannot be reproduced with mocks, because there is nothing to +The failure is a deadlock — `DELETE FROM rulebooks` killed by the server +while a detached `upsert_rule_embedding` holds the other half of the cycle. +It cannot be reproduced with mocks, because there is nothing to deadlock: the whole bug lives in the ORDER two transactions take two row locks, which only a lock manager can adjudicate. So each test here holds a real delete open in one transaction and calls the embedder in another. What is being pinned is that the embedder RETURNS — -promptly, having written nothing. Before the fix it would sit on the chunk -rows waiting for a delete that is itself waiting on the insert's foreign key, -and the test would hang rather than fail, which is why every call carries a -deadline (rule 156). +promptly, having written nothing. An embedder that sits on the chunk rows +waiting for a delete that is itself waiting on the insert's foreign key would +hang the test rather than fail it, which is why every call carries a +deadline. """ import asyncio from unittest.mock import AsyncMock, patch @@ -20,12 +20,12 @@ import pytest import pytest_asyncio from sqlalchemy import delete, select -from scribe.models import async_session -from scribe.models.embedding import NoteEmbedding, RuleEmbedding -from scribe.models.note import Note -from scribe.models.rulebook import Rulebook -from scribe.services import embeddings as emb -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.embedding import NoteEmbedding, RuleEmbedding +from grimoire.models.note import Note +from grimoire.models.rulebook import Rulebook +from grimoire.services import embeddings as emb +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -53,8 +53,7 @@ SETTLE_DEADLINE_SECONDS = 30 async def seeded(): """A rule and a note to race against. - CLEANED AT SETUP, NOT TEARDOWN — the same constraint #3241 hit and the - reason this file exists. `create_rule` fires its own detached embedding + CLEANED AT SETUP, NOT TEARDOWN. `create_rule` fires its own detached embedding task; a teardown that deleted the rulebook would be racing exactly the thing under test, on a loop that is closing. """ @@ -141,7 +140,7 @@ async def test_a_rule_refresh_yields_to_a_delete_cascading_from_its_rulebook(see async def test_a_note_refresh_yields_to_a_delete_of_the_note(seeded): - """The note twin, which #3262 recorded as unverified. Notes are soft-deleted + """The note twin. Notes are soft-deleted day to day, so the hard delete a trash purge issues is the one that can put a lock on the row while a refresh is in flight.""" async with async_session() as blocker: diff --git a/tests/test_integration_family_adoption.py b/tests/test_integration_family_adoption.py index dda4a712..333f6705 100644 --- a/tests/test_integration_family_adoption.py +++ b/tests/test_integration_family_adoption.py @@ -1,4 +1,4 @@ -"""The adoption ledger against real Postgres (milestone 463 step 4). +"""The adoption ledger against real Postgres. What the unit lane can't show: an answer moves the row and logs exactly one decision; an owed answer files its task in the OWING project, tagged to the @@ -19,16 +19,16 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.canonical_system import CanonicalSystem -from scribe.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.system import RecordSystem, System -from scribe.models.task_log import TaskLog -from scribe.models.user import User -from scribe.services import family as family_svc -from scribe.services import family_adoption as adoption_svc +from grimoire.models import async_session +from grimoire.models.canonical_system import CanonicalSystem +from grimoire.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.system import RecordSystem, System +from grimoire.models.task_log import TaskLog +from grimoire.models.user import User +from grimoire.services import family as family_svc +from grimoire.services import family_adoption as adoption_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -63,7 +63,7 @@ async def _clean(): @pytest.fixture(autouse=True) def _no_meaning(): - with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])): + with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])): yield @@ -236,7 +236,7 @@ async def test_another_projects_answer_is_recorded_as_precedent(family): assert first["decision"]["id"] in second["decision"]["precedent_ids"] assert second["precedents"][0]["relation"] == "same idea, another project" assert second["precedents"][0]["project_title"] == "android one" - # A precedent says its reason once: before/after are state only (#5131). + # A precedent says its reason once: before/after are state only. assert second["precedents"][0]["reason"] == "the source app" assert "reason" not in second["precedents"][0]["after"] @@ -392,7 +392,7 @@ async def test_the_matrix_hides_what_the_caller_cannot_read(family): assert all(i["note_id"] != family["note"] for i in matrix["ideas"]) -# --- the shape ledger as evidence (step 5) ------------------------------------------- +# --- the shape ledger as evidence ---------------------------------------------------- REPO = "git.example/android-two" KOTLIN_BODY = ( @@ -409,7 +409,7 @@ B_SHAPES = [ async def _reference(f, *, language: str = "kotlin", name: str = "installUpdate") -> int: - from scribe.services import snippets as snippets_svc + from grimoire.services import snippets as snippets_svc snippet = await snippets_svc.create_snippet( f["owner"], name=f"{name} ({language})", code=KOTLIN_BODY, language=language, @@ -422,7 +422,7 @@ async def _reference(f, *, language: str = "kotlin", name: str = "installUpdate" async def shapes(family): """Android two's ledger has two live shapes, and the idea has a Kotlin reference in android one.""" - from scribe.services.shape_ledger import sync_repo_shapes + from grimoire.services.shape_ledger import sync_repo_shapes await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main") family["kotlin"] = await _reference(family) @@ -431,7 +431,7 @@ async def shapes(family): async def _classify(f, project: str, symbol: str, status: str, **item): - from scribe.services import shape_ledger + from grimoire.services import shape_ledger path = next(p for p, _, s in B_SHAPES if s == symbol) return await shape_ledger.classify_shapes( @@ -461,7 +461,7 @@ async def test_a_shape_classified_against_the_idea_answers_it_adopted(shapes): async def test_idea_id_prefers_the_reference_in_the_shapes_language(shapes): - from scribe.services.shape_ledger import live_rows + from grimoire.services.shape_ledger import live_rows python_ref = await _reference(shapes, language="python", name="install_update") await adoption_svc.set_references(shapes["owner"], shapes["note"], @@ -473,7 +473,7 @@ async def test_idea_id_prefers_the_reference_in_the_shapes_language(shapes): async def test_an_idea_with_no_reference_is_refused_and_nothing_applies(family): - from scribe.services.shape_ledger import live_rows, sync_repo_shapes + from grimoire.services.shape_ledger import live_rows, sync_repo_shapes await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main") with pytest.raises(ValueError, match="no reference implementation"): @@ -532,7 +532,7 @@ async def test_nothing_is_answered_or_proposed_across_projects_sharing_no_platfo """The Go service shares no platform with the idea: its shape judged against the reference is plain canon use, and the proposer never offers the reference to it.""" - from scribe.services import shape_ledger + from grimoire.services import shape_ledger await shape_ledger.sync_repo_shapes(shapes["c"], "git.example/go", [ ("cmd/update.go", "sym", "PushUpdate")], seen_marker="main") @@ -548,7 +548,7 @@ async def test_nothing_is_answered_or_proposed_across_projects_sharing_no_platfo async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes): - from scribe.services import shape_ledger + from grimoire.services import shape_ledger body = ("def push_update(apk_path: str) -> None:\n" " session = installer.open_session(installer.create_session())\n" @@ -557,7 +557,7 @@ async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes): defs = [("tools/release.py", "sym", "push_update", "def push_update(apk_path: str) -> None:", "sha-push", body)] ref = type("Hit", (), {"id": shapes["kotlin"]})() - with patch("scribe.services.embeddings.semantic_search_notes", + with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[(0.73, ref)])): await shape_ledger.propose_for_repo(shapes["owner"], shapes["b"], REPO, defs) row = next(r for r in await shape_ledger.live_rows(shapes["b"]) if r.symbol == "push_update") @@ -568,7 +568,7 @@ async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes): async def test_a_conflict_brings_the_losing_sides_variant_shapes_back_to_the_todo(shapes): - from scribe.services.shape_ledger import live_rows + from grimoire.services.shape_ledger import live_rows await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"], reason="signs in CI with a throwaway key") diff --git a/tests/test_integration_family_canon.py b/tests/test_integration_family_canon.py index a51a102b..30061179 100644 --- a/tests/test_integration_family_canon.py +++ b/tests/test_integration_family_canon.py @@ -1,4 +1,4 @@ -"""Real-Postgres checks for family canon's schema (milestone 463 step 1). +"""Real-Postgres checks for family canon's schema. Two kinds of claim live here, and both need a database: @@ -21,15 +21,15 @@ import pytest_asyncio from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from scribe.models import async_session -from scribe.models.family import ( +from grimoire.models import async_session +from grimoire.models.family import ( FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference, Platform, ProjectPlatform, ) -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.user import User -from scribe.services import backup +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.user import User +from grimoire.services import backup from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] diff --git a/tests/test_integration_family_promotion.py b/tests/test_integration_family_promotion.py index 39dc8479..618b1347 100644 --- a/tests/test_integration_family_promotion.py +++ b/tests/test_integration_family_promotion.py @@ -1,4 +1,4 @@ -"""The promotion engine against real Postgres (milestone 463 step 3). +"""The promotion engine against real Postgres. What the unit lane can't show: a promotion opens the right ledger rows and no others, a veto changes nothing but the log, an undo puts back exactly the @@ -18,16 +18,16 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.family import ( +from grimoire.models import async_session +from grimoire.models.family import ( FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform, ) -from scribe.models.milestone import Milestone -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.rulebook import Rule, Rulebook, RulebookTopic -from scribe.models.user import User -from scribe.services import family as family_svc +from grimoire.models.milestone import Milestone +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic +from grimoire.models.user import User +from grimoire.services import family as family_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -62,7 +62,7 @@ async def _clean(): @pytest.fixture(autouse=True) def _no_meaning(): - with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])): + with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])): yield @@ -261,7 +261,7 @@ async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family): repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.") async with async_session() as s: original = await s.get(Note, family["note"]) - with patch("scribe.services.embeddings.semantic_search_notes", + with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[(0.86, original)])): hint = await family_svc.repeat_trigger(family["owner"], repeat) assert hint and f"#{family['note']}" in hint @@ -272,16 +272,16 @@ async def test_a_repeat_with_no_shared_platform_is_silent(family): unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.") async with async_session() as s: original = await s.get(Note, family["note"]) - with patch("scribe.services.embeddings.semantic_search_notes", + with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[(0.86, original)])): assert await family_svc.repeat_trigger(family["owner"], unrelated) is None assert await _idea(family["note"]) is None async def test_a_repeat_across_kinds_is_silent(family): - """A snippet whose only match is a NOTE is not a repeat (#5130): a dated - release log scored 0.83 against a signed-APK CI snippet on shared - vocabulary and was opened as a candidate. The stub hands back the + """A snippet whose only match is a NOTE is not a repeat: a dated + release log can score above the bar against a signed-APK CI snippet on + shared vocabulary alone, and would open as a candidate. The stub hands back the cross-kind hit directly, so this pins the trigger's own check, not the search filter in front of it.""" async with async_session() as s: @@ -292,7 +292,7 @@ async def test_a_repeat_across_kinds_is_silent(family): await s.refresh(snippet) original = await s.get(Note, family["note"]) assert original.note_type == "note" - with patch("scribe.services.embeddings.semantic_search_notes", + with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[(0.86, original)])): assert await family_svc.repeat_trigger(family["owner"], snippet) is None assert await _idea(family["note"]) is None diff --git a/tests/test_integration_family_reach.py b/tests/test_integration_family_reach.py index 45e5e92e..b620c3f1 100644 --- a/tests/test_integration_family_reach.py +++ b/tests/test_integration_family_reach.py @@ -1,7 +1,6 @@ -"""A family idea reaches every project on its platform, and no other -(milestone 463 step 6). +"""A family idea reaches every project on its platform, and no other. -WHY THIS IS AN INTEGRATION TEST — the lesson-reach reasoning (#3730) holds +WHY THIS IS AN INTEGRATION TEST — the lesson-reach reasoning holds here too: the reach is one `OR` inside the search's project filter, and only the ROWS that come back prove it. Every note embeds identically to the query, so scoping is the only thing that can separate them. The embedder is stubbed; @@ -19,16 +18,16 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding -from scribe.models.family import ( +from grimoire.models import async_session +from grimoire.models.embedding import EMBEDDING_DIM, NoteEmbedding +from grimoire.models.family import ( FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference, Platform, ProjectPlatform, ) -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.services import family_adoption as adoption_svc -from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.services import family_adoption as adoption_svc +from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -103,7 +102,7 @@ def _family(corpus) -> set[int]: async def _search(uid, **kw): - with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)): + with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)): hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw) return {note.id for _score, note in hits} diff --git a/tests/test_integration_forge_keyring.py b/tests/test_integration_forge_keyring.py index 0bf28ee5..e3ae8ef9 100644 --- a/tests/test_integration_forge_keyring.py +++ b/tests/test_integration_forge_keyring.py @@ -1,4 +1,4 @@ -"""Real-Postgres integration tests for the forge keyring (#2778). +"""Real-Postgres integration tests for the forge keyring. Runs only in the CI integration lane (real Postgres, schema built by `alembic upgrade head`, which includes migration 0078's forge_connections @@ -14,11 +14,11 @@ from unittest.mock import patch import pytest import pytest_asyncio -from scribe.config import Config -from scribe.models import async_session -from scribe.models.project import Project -from scribe.services.forge import get_forges -from scribe.services.forge_connections import ( +from grimoire.config import Config +from grimoire.models import async_session +from grimoire.models.project import Project +from grimoire.services.forge import get_forges +from grimoire.services.forge_connections import ( create_connection, delete_connection, list_connections, @@ -178,7 +178,7 @@ async def test_the_token_is_stored_sealed_and_a_key_change_drops_the_row(seeded) rather than sending ciphertext to the forge as a token.""" from sqlalchemy import text - from scribe.services import secrets + from grimoire.services import secrets owner = seeded["owner"] row = await create_connection(owner, kind="gitea", base_url=GITEA, token="plain-tok") diff --git a/tests/test_integration_handshake_touched.py b/tests/test_integration_handshake_touched.py index 0efbebd5..7d10d7e8 100644 --- a/tests/test_integration_handshake_touched.py +++ b/tests/test_integration_handshake_touched.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for "recently touched" in the enter_project handshake (#4045). +"""Real-Postgres tests for "recently touched" in the enter_project handshake. What a mock cannot show: that a work-log moves its task up the list even though logging never bumps the task's updated_at, and that a milestone whose @@ -9,13 +9,13 @@ from datetime import datetime, timedelta, timezone import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.milestone import Milestone -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.task_log import TaskLog -from scribe.services import milestones as milestones_svc -from scribe.services import notes as notes_svc +from grimoire.models import async_session +from grimoire.models.milestone import Milestone +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.task_log import TaskLog +from grimoire.services import milestones as milestones_svc +from grimoire.services import notes as notes_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")] diff --git a/tests/test_integration_inception.py b/tests/test_integration_inception.py index abfd372a..bfb70837 100644 --- a/tests/test_integration_inception.py +++ b/tests/test_integration_inception.py @@ -1,20 +1,20 @@ -"""Real-Postgres integration tests for project inception (milestone 297). +"""Real-Postgres integration tests for project inception. What mocks can't prove: a decision's effects land through the real services (the design system points, the standard Systems seed once), the record is written last, a bad target applies nothing. -Rules are not part of inception since milestone 414: a rule in a rulebook is +Rules are not part of inception: a rule in a rulebook is global and applies to every project, so there is nothing to subscribe to. """ import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.project import Project -from scribe.services import inception as inception_svc -from scribe.services import canonical_systems as canonical_svc -from scribe.services import systems as systems_svc +from grimoire.models import async_session +from grimoire.models.project import Project +from grimoire.services import inception as inception_svc +from grimoire.services import canonical_systems as canonical_svc +from grimoire.services import systems as systems_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] diff --git a/tests/test_integration_lesson_kind.py b/tests/test_integration_lesson_kind.py index 9d907cf1..15d31238 100644 --- a/tests/test_integration_lesson_kind.py +++ b/tests/test_integration_lesson_kind.py @@ -1,17 +1,17 @@ -"""A lesson is a row the database actually accepts (milestone 385 step 2). +"""A lesson is a row the database actually accepts. WHY THIS IS THE REAL GUARD, AND WHY IT IS HERE -The step asked for "a guard that the CHECK actually accepts `lesson` and -rejects a typo", citing #3128 — a `spike` kind that could not be written on an -instance predating its migration. That failure mode belongs to `task_kind`, +The obvious ask is a guard that a CHECK accepts `lesson` and rejects a typo — +the failure where a new kind cannot be written on an instance predating its +migration. That failure mode belongs to `task_kind`, which IS gated (`notes_task_kind_check`, migrations 0056 and 0065). `note_type` is not gated at all: migration 0036 added it as plain Text with a server default and nothing has constrained it since. So there is no whitelist to expand and no rejection to assert. Writing the -guard as "the constraint admits lesson" would have pinned a constraint that -does not exist; writing it as "no constraint exists" would pin today's schema +guard as "the constraint admits lesson" would pin a constraint that does not +exist; writing it as "no constraint exists" would pin today's schema rather than the behaviour that matters, and would go red on a change that is perfectly fine. @@ -19,7 +19,7 @@ Asserting the WRITE covers both worlds. It passes today, it keeps passing if a CHECK is added that admits `lesson`, and it goes red the day one is added that does not — which is the only outcome anyone needs to be told about. -The second test is the cell #3163 asks to be left empty on purpose: a lesson +The second test is the cell left empty on purpose: a lesson must not be a task. `is_task` is a read-only property over `status`, so this cannot be asserted by setting a flag — it has to be observed on a real row. """ @@ -27,10 +27,10 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.note import Note -from scribe.services import lessons as lessons_svc -from scribe.services import notes as notes_svc +from grimoire.models import async_session +from grimoire.models.note import Note +from grimoire.services import lessons as lessons_svc +from grimoire.services import notes as notes_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -83,13 +83,13 @@ async def test_a_lesson_is_a_row_the_database_accepts(owner_id): # and the readable body — because the vector is built from the text and # the queries are built from the mirror. assert lessons_svc.lesson_trigger(stored) == TRIGGER - # The subject alone (milestone 427): the trigger is in `data` and the body. + # The subject alone: the trigger is in `data` and the body. assert stored.title == SUBJECT assert "**When to apply:**" in (stored.body or "") async def test_a_lesson_is_not_a_task(owner_id): - """#3163's cell left empty on purpose. + """The cell left empty on purpose. `is_task` is `status is not None` and is read-only, so this is only observable on a stored row. A lesson that arrived with a status would join diff --git a/tests/test_integration_lesson_reach.py b/tests/test_integration_lesson_reach.py index 7f996d5e..ab15534f 100644 --- a/tests/test_integration_lesson_reach.py +++ b/tests/test_integration_lesson_reach.py @@ -1,4 +1,4 @@ -"""A lesson is reachable from a project it was not written on (step 3). +"""A lesson is reachable from a project it was not written on. WHY THIS IS AN INTEGRATION TEST @@ -18,12 +18,12 @@ from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.services import lessons as lessons_svc -from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes +from grimoire.models import async_session +from grimoire.models.embedding import EMBEDDING_DIM, NoteEmbedding +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.services import lessons as lessons_svc +from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -81,7 +81,7 @@ async def corpus(): async def _search(uid, **kw): with patch( - "scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC) + "grimoire.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC) ): hits = await semantic_search_notes(uid, "when does this apply", limit=20, **kw) return {note.id for _score, note in hits} diff --git a/tests/test_integration_lesson_rule_links.py b/tests/test_integration_lesson_rule_links.py index fab8bb67..6284d4e8 100644 --- a/tests/test_integration_lesson_rule_links.py +++ b/tests/test_integration_lesson_rule_links.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for lesson → rule links (milestone 440 step 1, #4630). +"""Real-Postgres tests for lesson → rule links. What a mock cannot show: that the CHECK holds the three states, that deleting either end takes the link with it, that a rule the caller does not own cannot @@ -13,14 +13,14 @@ import pytest_asyncio from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError -from scribe.models import async_session -from scribe.models.lesson_rule_link import LessonRuleLink -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.rulebook import Rule -from scribe.services import lesson_rules as links_svc -from scribe.services import lessons as lessons_svc -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.lesson_rule_link import LessonRuleLink +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.rulebook import Rule +from grimoire.services import lesson_rules as links_svc +from grimoire.services import lessons as lessons_svc +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [ @@ -32,7 +32,7 @@ pytestmark = [ @pytest.fixture(autouse=True) def _no_reindex(): """Rule writes detach an embedding refresh that outlives the test's loop.""" - with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()): + with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()): yield @@ -162,7 +162,7 @@ async def test_one_row_per_pair(world): await s.commit() -# ── #4631: "no rule fits" and the unjudged list ───────────────────────────── +# ── "no rule fits" and the unjudged list ──────────────────────────────────── async def _judgment(lesson_id: int) -> dict: @@ -231,7 +231,7 @@ async def test_the_unjudged_list_is_scoped_to_the_reader(world): async def test_deleting_the_lesson_takes_its_answer_with_it(world): - from scribe.models.lesson_rule_link import LessonNoRule + from grimoire.models.lesson_rule_link import LessonNoRule await links_svc.set_no_rule(world["owner"], world["lesson"], "alone") async with async_session() as s: @@ -243,7 +243,7 @@ async def test_deleting_the_lesson_takes_its_answer_with_it(world): assert left == [] -# ── #4637: soft links from co-surfacing ───────────────────────────────────── +# ── soft links from co-surfacing ──────────────────────────────────────────── async def _link(lesson_id: int, rule_id: int): @@ -316,12 +316,12 @@ async def test_confirming_a_suggestion_keeps_what_it_rested_on(world): assert len(out["evidence"]["situations"]) == 3 -# ── #4633: which links can carry a rule ───────────────────────────────────── +# ── which links can carry a rule ──────────────────────────────────────────── async def test_only_a_confirmed_link_can_carry_its_rule(world): """A suggested link never expands: it would manufacture the co-arrival - that #4637 counts, and prove itself. A rejected one obviously not.""" + that co-surfacing counts, and prove itself. A rejected one obviously not.""" owner, lesson = world["owner"], world["lesson"] await links_svc.co_surfaced(owner, [lesson], [world["r1"]], arm="p", situation="one ask here") await links_svc.judge_link(owner, lesson, world["r2"], "reject", "not this") @@ -335,7 +335,7 @@ async def test_only_a_confirmed_link_can_carry_its_rule(world): async def test_a_project_rule_stays_in_its_project_even_through_a_lesson(world): - """Milestone 414's scope, kept on the side door: the lesson surfaces + """Rule scope, kept on the side door: the lesson surfaces everywhere, its project rule only in its project.""" owner, lesson = world["owner"], world["lesson"] await links_svc.set_lesson_rules(owner, lesson, [world["r1"]]) @@ -349,7 +349,7 @@ async def test_a_stranger_reaches_no_rule_through_someone_elses_link(world): assert await links_svc.confirmed_lessons(world["stranger"]) == set() -# ── #4634: which lessons count toward convergence ─────────────────────────── +# ── which lessons count toward convergence ────────────────────────────────── async def test_only_answered_lessons_are_no_rule_lessons(world): diff --git a/tests/test_integration_milestone_search.py b/tests/test_integration_milestone_search.py index b786a929..d3a328bd 100644 --- a/tests/test_integration_milestone_search.py +++ b/tests/test_integration_milestone_search.py @@ -1,8 +1,7 @@ -"""Real-Postgres tests for finding a plan by meaning (milestone 415, steps 3 and 4). +"""Real-Postgres tests for finding a plan by meaning. -A project's roadmap written as milestones was invisible to recall: `search` -covered notes, tasks and rules, so "is there already a plan for this?" had no -tool. What a mock cannot show is the join scoping the vectors to a project and +A project's roadmap is written as milestones, and `search` reaches them, so +"is there already a plan for this?" has a tool. What a mock cannot show is the join scoping the vectors to a project and to what the caller may read, so these seed real milestones with hand-made vectors and stub only the embedder. """ @@ -12,12 +11,12 @@ from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.embedding import EMBEDDING_DIM, MilestoneEmbedding -from scribe.models.milestone import Milestone -from scribe.models.project import Project -from scribe.services import dedup as dedup_svc -from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_milestones +from grimoire.models import async_session +from grimoire.models.embedding import EMBEDDING_DIM, MilestoneEmbedding +from grimoire.models.milestone import Milestone +from grimoire.models.project import Project +from grimoire.services import dedup as dedup_svc +from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_milestones from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")] @@ -57,7 +56,7 @@ async def roadmap(): async def _found(user_id, **kw) -> list[int]: - with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)): + with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)): hits = await semantic_search_milestones(user_id, "book metadata and providers", threshold=0.5, limit=10, **kw) return [m.id for _s, m in hits] @@ -84,13 +83,13 @@ async def test_a_project_the_caller_cannot_read_returns_nothing(roadmap): assert await _found(roadmap["stranger"]) == [] -# ── the plan gate (step 4): start_planning finds the plan before making another ── +# ── the plan gate: start_planning finds the plan before making another ── LONG = "Resolve works and editions against the metadata providers. " * 5 async def _gate(roadmap, title, text="", project=None): - with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)): + with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)): return await dedup_svc.plan_gate( roadmap["owner"], project or roadmap["mine"], title, text, ) diff --git a/tests/test_integration_moment_mappings.py b/tests/test_integration_moment_mappings.py index 2446a52a..3866cdc0 100644 --- a/tests/test_integration_moment_mappings.py +++ b/tests/test_integration_moment_mappings.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for an install's moment mappings (milestone 458 step 2). +"""Real-Postgres tests for an install's moment mappings. What these pin is what the in-session correction promises the operator: a mapping made is in force on the next call, a default switched off stays off, @@ -10,9 +10,9 @@ import pytest import pytest_asyncio from sqlalchemy import delete, select -from scribe.models import async_session -from scribe.models.moment_mapping import MomentMapping -from scribe.services import moment_actions as ma +from grimoire.models import async_session +from grimoire.models.moment_mapping import MomentMapping +from grimoire.services import moment_actions as ma from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] diff --git a/tests/test_integration_pgvector_search.py b/tests/test_integration_pgvector_search.py index d36ee631..805bd0f9 100644 --- a/tests/test_integration_pgvector_search.py +++ b/tests/test_integration_pgvector_search.py @@ -16,11 +16,11 @@ import pytest import pytest_asyncio from sqlalchemy import delete -from scribe.models import async_session -from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding -from scribe.models.note import Note -from scribe.models.user import User -from scribe.services.embeddings import semantic_search_notes +from grimoire.models import async_session +from grimoire.models.embedding import EMBEDDING_DIM, NoteEmbedding +from grimoire.models.note import Note +from grimoire.models.user import User +from grimoire.services.embeddings import semantic_search_notes pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -32,8 +32,8 @@ def _vec(*nonzero_first): def _emb(note_id, user_id, chunk_index, vec): - """A chunk row at the current chunker version (#280, migration 0077).""" - from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL + """A chunk row at the current chunker version.""" + from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL return NoteEmbedding( note_id=note_id, @@ -63,7 +63,7 @@ async def seeded(): # query vector will be [1,0,0,...]; near ~ identical (sim≈1.0), # far is orthogonal (sim≈0.0 -> filtered by the default threshold). # near gets a SECOND, weaker chunk (sim≈0.6) — the collapse to - # best-chunk-per-note (#280) is under test: near must come back once, + # best-chunk-per-note is under test: near must come back once, # at its best chunk's score, not twice. s.add(_emb(near.id, user.id, 0, _vec(1.0))) s.add(_emb(near.id, user.id, 1, _vec(0.6, 0.8))) @@ -83,7 +83,7 @@ async def seeded(): async def test_semantic_search_ranks_and_thresholds_via_pgvector(seeded): user_id, near_id, far_id = seeded with patch( - "scribe.services.embeddings.get_embedding", + "grimoire.services.embeddings.get_embedding", AsyncMock(return_value=_vec(1.0)), ): results = await semantic_search_notes(user_id=user_id, query="anything", limit=10) @@ -94,7 +94,7 @@ async def test_semantic_search_ranks_and_thresholds_via_pgvector(seeded): assert near_id in ids assert far_id not in ids assert ids[0] == near_id - # Chunk collapse (#280): near has TWO chunk rows above the floor (sim≈1.0 + # Chunk collapse: near has TWO chunk rows above the floor (sim≈1.0 # and ≈0.6) and must appear exactly once, at its best chunk's score. assert ids.count(near_id) == 1 top_score = results[0][0] @@ -105,7 +105,7 @@ async def test_semantic_search_ranks_and_thresholds_via_pgvector(seeded): async def test_low_threshold_lets_orthogonal_through(seeded): user_id, near_id, far_id = seeded with patch( - "scribe.services.embeddings.get_embedding", + "grimoire.services.embeddings.get_embedding", AsyncMock(return_value=_vec(1.0)), ): results = await semantic_search_notes( diff --git a/tests/test_integration_placement.py b/tests/test_integration_placement.py index 633cf7e3..bd3f8420 100644 --- a/tests/test_integration_placement.py +++ b/tests/test_integration_placement.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for task placement (milestone 409 step 1). +"""Real-Postgres tests for task placement. What a mock cannot show: that step order is creation order rather than update order, that position and progress are computed over the steps the CALLER may @@ -8,13 +8,13 @@ of steps they cannot open. import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.share import NoteShare, ProjectShare -from scribe.services import notes as notes_svc -from scribe.services.placement import task_placement -from scribe.services.record_batch import BatchItem, BatchMilestone, create_batch +from grimoire.models import async_session +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.share import NoteShare, ProjectShare +from grimoire.services import notes as notes_svc +from grimoire.services.placement import task_placement +from grimoire.services.record_batch import BatchItem, BatchMilestone, create_batch from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")] diff --git a/tests/test_integration_platforms.py b/tests/test_integration_platforms.py index 2b152ef2..9c981403 100644 --- a/tests/test_integration_platforms.py +++ b/tests/test_integration_platforms.py @@ -1,4 +1,4 @@ -"""Real-Postgres checks for platform membership (milestone 463 step 2). +"""Real-Postgres checks for platform membership. The one invariant: detection only ever ADDS, and only where nobody has answered. A refresh that overwrote a "no" would put a project back into a @@ -10,11 +10,11 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.family import Platform -from scribe.models.project import Project -from scribe.services import inception as inception_svc -from scribe.services import platforms as platforms_svc +from grimoire.models import async_session +from grimoire.models.family import Platform +from grimoire.models.project import Project +from grimoire.services import inception as inception_svc +from grimoire.services import platforms as platforms_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] diff --git a/tests/test_integration_preference_drift.py b/tests/test_integration_preference_drift.py index cdeb8e0e..69e9f275 100644 --- a/tests/test_integration_preference_drift.py +++ b/tests/test_integration_preference_drift.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for the drift surface (milestone 399 step 5, #3895). +"""Real-Postgres tests for the drift surface. WHY THIS EXISTS @@ -20,19 +20,19 @@ subquery picking the newest version per rule, an ownership clause spanning two paths, and a write that has to leave the history longer than it found it. A stand-in session returns whatever the test handed it, so it could confirm none of those. `test_the_restore_is_recorded_as_an_edit` is the one that matters -most: it is the whole basis for allowing a one-click revert here when -milestone 323 refused one for rules, and if it were ever to pass while the +most: it is the whole basis for allowing a one-click revert here when rules +have none, and if it were ever to pass while the restore silently overwrote history, the carve-out would be indefensible. """ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.note import Note -from scribe.models.rulebook import Rulebook -from scribe.models.rule_version import RuleVersion -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.note import Note +from grimoire.models.rulebook import Rulebook +from grimoire.models.rule_version import RuleVersion +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -228,8 +228,8 @@ async def test_restoring_puts_the_old_wording_back(world): async def test_the_restore_is_recorded_as_an_edit(world): """THE ONE THAT MATTERS MOST — it is why this carve-out is defensible. - Milestone 323 refused a one-click restore for rules because "a silent - revert would erase the only record of why the rewrite happened". The + Rules have no one-click restore because a silent revert would erase the + only record of why the rewrite happened. The preference carve-out survives that objection only while nothing is erased: the restore goes through `update_rule`, so it takes its own snapshot and the history GAINS an entry. @@ -262,7 +262,7 @@ async def test_the_restore_is_recorded_as_an_edit(world): async def test_a_rule_refuses_the_one_click_restore(world): - """Milestone 323 stands for rules, and the kind check is in the service + """Rules still have no restore, and the kind check is in the service rather than only in the route — so a caller reaching this directly cannot forget it.""" await rulebooks_svc.update_rule( diff --git a/tests/test_integration_record_batch.py b/tests/test_integration_record_batch.py index d85c5145..6921a34f 100644 --- a/tests/test_integration_record_batch.py +++ b/tests/test_integration_record_batch.py @@ -1,4 +1,4 @@ -"""Real-Postgres proof that a batch create cannot be interfered with (#4016). +"""Real-Postgres proof that a batch create cannot be interfered with. What mocks cannot show: that the ids a placeholder is rewritten to are the ids the rows actually got, that two batches running at the same time each cite @@ -12,13 +12,13 @@ import pytest import pytest_asyncio from sqlalchemy import func, select -from scribe.models import async_session -from scribe.models.milestone import Milestone -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.services import record_refs -from scribe.services.planning import start_planning -from scribe.services.record_batch import BatchItem, create_batch +from grimoire.models import async_session +from grimoire.models.milestone import Milestone +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.services import record_refs +from grimoire.services.planning import start_planning +from grimoire.services.record_batch import BatchItem, create_batch from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")] diff --git a/tests/test_integration_repo_move.py b/tests/test_integration_repo_move.py new file mode 100644 index 00000000..0d9b1f88 --- /dev/null +++ b/tests/test_integration_repo_move.py @@ -0,0 +1,102 @@ +"""Real-Postgres integration tests for moving a renamed repo's binding. + +A repo renamed on its forge has a new remote. The binding and every ledger row +are keyed by the remote, so the move has to carry both, in one transaction, and +refuse anything that would merge two ledgers or move another project's repo. +""" +import pytest +import pytest_asyncio +from sqlalchemy import select + +from grimoire.models import async_session +from grimoire.models.code_shape import CodeShape +from grimoire.models.project import Project +from grimoire.services import repo_bindings as svc +from grimoire.services.shape_ledger import classify_shapes, sync_repo_shapes +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + +OLD = "https://git.example.com/alice/OldName.git" +NEW = "git@git.example.com:alice/NewName.git" +OLD_KEY = "git.example.com/alice/oldname" +NEW_KEY = "git.example.com/alice/newname" +SHAPES = [("src/app.py", "sym", "make_app"), ("src/util.py", "sym", "helper")] + + +@pytest_asyncio.fixture +async def bound(): + """A project bound under the old name, its two-shape ledger, one judged.""" + async with async_session() as s: + owner = await ensure_user(s, "repo_move_owner") + project = Project(user_id=owner.id, title="Renamed repo") + other = Project(user_id=owner.id, title="Some other project") + s.add_all([project, other]) + await s.flush() + ids = {"owner": owner.id, "pid": project.id, "other": other.id} + await s.commit() + + await svc.set_binding(ids["owner"], OLD, ids["pid"], "dev") + await sync_repo_shapes(ids["pid"], OLD_KEY, SHAPES, seen_marker="dev") + await classify_shapes(ids["owner"], ids["pid"], [ + {"path": "src/util.py", "symbol": "helper", "status": "exempt", + "reason": "one caller"}, + ]) + yield ids + await svc.delete_binding(ids["owner"], OLD) + await svc.delete_binding(ids["owner"], NEW) + + +async def _ledger(pid: int) -> dict[str, tuple[str, str]]: + async with async_session() as s: + rows = await s.execute(select(CodeShape).where(CodeShape.project_id == pid)) + return {r.symbol: (r.repo_key, r.status) for r in rows.scalars()} + + +@pytest.mark.integration +async def test_the_binding_and_every_judgment_move_to_the_new_name(bound): + binding, moved = await svc.move_binding(bound["owner"], OLD, NEW, bound["pid"]) + + assert moved == 2 + assert binding.repo_key == NEW_KEY + assert binding.project_id == bound["pid"] + assert binding.ref == "dev" + assert await svc.resolve_project(bound["owner"], NEW) == bound["pid"] + assert await svc.resolve_project(bound["owner"], OLD) is None + assert await _ledger(bound["pid"]) == { + "make_app": (NEW_KEY, "unclassified"), + "helper": (NEW_KEY, "exempt"), + } + + +@pytest.mark.integration +async def test_it_refuses_a_repo_bound_to_another_project(bound): + with pytest.raises(ValueError, match="not bound to project"): + await svc.move_binding(bound["owner"], OLD, NEW, bound["other"]) + assert {k for k, _ in (await _ledger(bound["pid"])).values()} == {OLD_KEY} + + +@pytest.mark.integration +async def test_it_refuses_when_the_new_name_is_already_bound(bound): + await svc.set_binding(bound["owner"], NEW, bound["pid"]) + with pytest.raises(ValueError, match="already bound"): + await svc.move_binding(bound["owner"], OLD, NEW, bound["pid"]) + assert {k for k, _ in (await _ledger(bound["pid"])).values()} == {OLD_KEY} + + +@pytest.mark.integration +async def test_it_refuses_to_merge_into_a_ledger_the_new_name_already_has(bound): + await sync_repo_shapes( + bound["pid"], NEW_KEY, [("src/new.py", "sym", "fresh")], seen_marker="dev", + ) + with pytest.raises(ValueError, match="already has a ledger"): + await svc.move_binding(bound["owner"], OLD, NEW, bound["pid"]) + assert await svc.resolve_project(bound["owner"], OLD) == bound["pid"] + + +@pytest.mark.integration +async def test_the_same_repo_under_both_names_is_refused(bound): + with pytest.raises(ValueError, match="same repo"): + await svc.move_binding( + bound["owner"], OLD, "git@git.example.com:alice/oldname", bound["pid"], + ) diff --git a/tests/test_integration_rule_kind.py b/tests/test_integration_rule_kind.py index 613df6f8..49e73e65 100644 --- a/tests/test_integration_rule_kind.py +++ b/tests/test_integration_rule_kind.py @@ -1,6 +1,6 @@ -"""Real-Postgres tests for `rules.kind` and its CHECK (0098, #3849 step 1). +"""Real-Postgres tests for `rules.kind` and its CHECK (migration 0098). -Rule 36 exists because a value and its constraint drift apart: the code +A value and its constraint drift apart: the code starts writing a new kind while the database still refuses it, and nothing catches it until a write fails in front of someone. A mock cannot show that — it has no CHECK — so the constraint gets a real-DB test, exactly as 0091's @@ -24,9 +24,9 @@ import pytest_asyncio from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from scribe.models import async_session -from scribe.models.rulebook import Rule, Rulebook -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.rulebook import Rule, Rulebook +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -94,8 +94,7 @@ async def test_an_unknown_kind_is_refused(topic_id): """The half that proves the constraint is there at all. Without this, every other assertion in this file would pass against a - table whose CHECK had been dropped — the exact failure rule 36 is written - against. + table whose CHECK had been dropped — the exact drift this guards. """ _uid, topic = topic_id with pytest.raises(IntegrityError): @@ -119,8 +118,8 @@ async def test_a_rule_can_become_a_preference_after_the_fact(topic_id): """The read-back is the whole test. A version asserting only that the update call succeeded would pass against - code that accepted `kind` and dropped it — which is how the same bug - survived on `task_kind` long enough to be found by hand (#3129). + code that accepted `kind` and dropped it — a bug that otherwise surfaces + only when someone finds it by hand. This is also the migration path the always-on triage needs: the rules that turn out to be preferences change one column and keep their id, history diff --git a/tests/test_integration_rule_moment_judgments.py b/tests/test_integration_rule_moment_judgments.py index 691d3eb4..cd361b82 100644 --- a/tests/test_integration_rule_moment_judgments.py +++ b/tests/test_integration_rule_moment_judgments.py @@ -1,6 +1,6 @@ -"""Real-Postgres tests for moment proposals and judgments (milestone 458 step 7). +"""Real-Postgres tests for moment proposals and judgments. -What the step promises, against the real tables: +What the feature promises, against the real tables: - the pass reads only rules nobody has answered for, and any answer — a proposal, a mount, "no moment fits" — takes a rule off that list; - a proposal mounts nothing; a confirm mounts, a reject is kept, and a judged @@ -8,7 +8,7 @@ What the step promises, against the real tables: - an edit that takes a moment off a rule is a rejection the signal respects; - the signal counts SESSIONS, not opens, and asks once the bar is crossed; - only the owner can propose, judge, or feed the signal; -- the misfire (step 7b): only a mount can misfire, reports count DAYS, the +- the misfire: only a mount can misfire, reports count DAYS, the bar makes an unmount proposal, reject takes it off, confirm keeps it and stops the asking, and a re-mount starts the count again. """ @@ -16,12 +16,12 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.rule_moment_judgment import RuleMomentJudgment -from scribe.models.rulebook import Rulebook -from scribe.models.user import User -from scribe.services import rule_moment_judgments as judgments_svc -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.rule_moment_judgment import RuleMomentJudgment +from grimoire.models.rulebook import Rulebook +from grimoire.models.user import User +from grimoire.services import rule_moment_judgments as judgments_svc +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -196,7 +196,7 @@ async def test_the_open_resolves_acts_through_the_installs_mappings(world): assert (await _row(done.id, "work.deliver")).source == "signal" -# ── The misfire (step 7b) ──────────────────────────────────────────────────── +# ── The misfire ────────────────────────────────────────────────────────────── @pytest.fixture @@ -207,7 +207,7 @@ def days(monkeypatch): queue: list[str] = [] def situation(_now): - from scribe.services import lesson_rules + from grimoire.services import lesson_rules return lesson_rules.situation_key("d", queue.pop(0)) monkeypatch.setattr(judgments_svc, "misfire_situation", situation) diff --git a/tests/test_integration_rule_moments.py b/tests/test_integration_rule_moments.py index 8b883453..ce6e1955 100644 --- a/tests/test_integration_rule_moments.py +++ b/tests/test_integration_rule_moments.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for rule mounts (milestone 458 step 3). +"""Real-Postgres tests for rule mounts. A mount is the claim "this rule arrives at this moment". These pin what that claim depends on: @@ -7,21 +7,21 @@ claim depends on: - the rule's detail reads its mounts back; - a backup carries them, attached to the RESTORED rule's new id rather than the old number; -- the delivery lookup (step 4) answers by home: a project's mounted rule +- the delivery lookup answers by home: a project's mounted rule reaches only work bound to that project, and nobody else's mount arrives. """ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.project import Project -from scribe.models.rule_moment_judgment import RuleMomentJudgment -from scribe.models.rulebook import Rule, Rulebook, RulebookTopic -from scribe.models.user import User -from scribe.services import backup -from scribe.services import projects as projects_svc -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.project import Project +from grimoire.models.rule_moment_judgment import RuleMomentJudgment +from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic +from grimoire.models.user import User +from grimoire.services import backup +from grimoire.services import projects as projects_svc +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -119,7 +119,7 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world): await rulebooks_svc.set_rule_moments(rule.id, uid, ["work.finish", "reply.report"]) # A misfire report on one of them (v24) — the operator's evidence for # taking a mount off must not be lost to a restore. - from scribe.services import rule_moment_judgments as judgments_svc + from grimoire.services import rule_moment_judgments as judgments_svc reported = await judgments_svc.misfired( uid, rule.id, "reply.report", why="the reply only asked a question", reached_by="the reply that ends this turn", @@ -150,7 +150,7 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world): assert sorted(r["moment"] for r in payload["rule_moments"]) == [ "reply.report", "work.finish", ] - # The mount was an edit, so it was also recorded as two judgments (step 7) + # The mount was an edit, so it was also recorded as two judgments # — and those travel too, onto the restored rule. payload["rule_moment_judgments"] = [ row for row in exported["rule_moment_judgments"] if row["rule_id"] == rule.id @@ -238,16 +238,16 @@ async def test_the_delivery_lookup_answers_by_home(world): async def test_moment_usage_counts_deliveries_and_the_agent_opens_that_followed(world): - """The per-moment readout (step 6), on real SQL: an EXISTS inside a + """The per-moment readout, on real SQL: an EXISTS inside a COUNT(CASE …) is a shape no mock can vouch for, and the block's own guard would turn a database refusal into a quiet `moment_usage_failed`.""" from datetime import datetime, timedelta, timezone from sqlalchemy import delete - from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent - from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE - from scribe.services.retrieval_telemetry import moment_usage + from grimoire.models.rule_usage import PULLED, SURFACED, RuleUsageEvent + from grimoire.services.retrieval_pipeline import MOMENT_RULE_SOURCE + from grimoire.services.retrieval_telemetry import moment_usage uid, sid, rule = world["uid"], world["sid"], world["rule"] other = await rulebooks_svc.create_rule( diff --git a/tests/test_integration_rule_move.py b/tests/test_integration_rule_move.py index 93ef55b9..0dbce95c 100644 --- a/tests/test_integration_rule_move.py +++ b/tests/test_integration_rule_move.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for moving a rule between homes (milestone 414, step 3). +"""Real-Postgres tests for moving a rule between homes. A rule's home is its reach: a rulebook topic makes it global, a project makes it that project's. The alternative to a move — recreate the rule in the other @@ -14,12 +14,12 @@ from unittest.mock import MagicMock, patch import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.project import Project -from scribe.models.rulebook import Rule -from scribe.services import canonical_systems as canonical_svc -from scribe.services import rule_versions as rv_svc -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.project import Project +from grimoire.models.rulebook import Rule +from grimoire.services import canonical_systems as canonical_svc +from grimoire.services import rule_versions as rv_svc +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user, rule_row pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -29,7 +29,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine" def _no_reindex(): """Rule writes detach an embedding refresh that outlives the test's loop and races the next fixture; nothing here is about recall.""" - with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()): + with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()): yield @@ -84,7 +84,7 @@ async def test_a_project_rule_becomes_global_and_keeps_everything(homes): async def test_a_move_writes_no_version(homes): - """A version records what a rule SAID (milestone 323, decision 4). A move + """A version records what a rule SAID. A move changes where it binds, not a word of it.""" before = len(await rv_svc.list_versions(homes["rule"])) await rulebooks_svc.move_rule(homes["rule"], homes["owner"], topic_id=homes["topic"]) diff --git a/tests/test_integration_rule_scope.py b/tests/test_integration_rule_scope.py index 8c551d3c..feb238cf 100644 --- a/tests/test_integration_rule_scope.py +++ b/tests/test_integration_rule_scope.py @@ -1,8 +1,8 @@ -"""Real-Postgres tests for WHERE a rule reaches (milestone 414, step 1). +"""Real-Postgres tests for WHERE a rule reaches. -A rule lives in a rulebook topic (global) or on one project. Retrieval used to -ignore that and search every rule the user owned, so each project's rules were -injected into every other project's sessions. What a mock cannot show is the +A rule lives in a rulebook topic (global) or on one project. Retrieval that +ignores the home and searches every rule the user owns injects each project's +rules into every other project's sessions. What a mock cannot show is the join doing the scoping: these seed real rules with hand-made vectors and stub only the embedder, so every rule is an equally good match and the home alone decides what comes back. @@ -14,12 +14,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.embedding import EMBEDDING_DIM, RuleEmbedding -from scribe.models.project import Project -from scribe.models.share import ProjectShare -from scribe.services import rulebooks as rulebooks_svc -from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_rules +from grimoire.models import async_session +from grimoire.models.embedding import EMBEDDING_DIM, RuleEmbedding +from grimoire.models.project import Project +from grimoire.models.share import ProjectShare +from grimoire.services import rulebooks as rulebooks_svc +from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_rules from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -46,7 +46,7 @@ async def homes(): ids = {"owner": owner.id, "collaborator": collaborator.id, "a": a.id, "b": b.id} await s.commit() - with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()): + with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()): book = await rulebooks_svc.create_rulebook(ids["owner"], "Scope house style") topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "everywhere") glob = await rulebooks_svc.create_rule( @@ -78,12 +78,12 @@ async def _found(user_id: int, **scope) -> set[int]: """The rule ids a search finds — and a failure, not an empty set, when the search never ran. `semantic_search_rules` fails open (an exception becomes []), so without `report["searched"]` an error and a scoping miss read the - same (#4958).""" + same.""" report: dict = {} raised: list[str] = [] - with patch("scribe.services.embeddings.get_embedding", + with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)), \ - patch("scribe.services.embeddings.logger") as log: + patch("grimoire.services.embeddings.logger") as log: # Called inside the except block, so the traceback is still current. log.warning.side_effect = lambda *_a, **_k: raised.append(traceback.format_exc()) hits = await semantic_search_rules(user_id, "anything", limit=10, @@ -109,7 +109,7 @@ async def test_retrieval_reaches_a_rule_only_from_its_home(homes): async def test_a_shared_project_brings_its_rules_to_a_collaborator(homes): - """Readability goes through access.can_read_project (rule 78): a viewer on + """Readability goes through access.can_read_project: a viewer on A gets A's rules. Not the owner's global rules — rulebooks are the owner's — and not B's, which is not shared.""" collaborator = homes["collaborator"] @@ -118,9 +118,9 @@ async def test_a_shared_project_brings_its_rules_to_a_collaborator(homes): async def test_an_in_scope_rule_is_found_however_many_closer_rules_are_out_of_scope(): - """#4958. Ordered through the HNSW index, the search took the ~40 nearest - chunks from every owner and filtered them AFTERWARDS — so a reader's own - rule ranked past the 40th chunk overall was never returned. Here another + """A search ordered through the HNSW index takes the ~40 nearest chunks + from every owner, and filtering them AFTERWARDS loses a reader's own rule + ranked past the 40th chunk overall. Here another user's 80 rules all sit nearer the query than the reader's one rule; the reader's search must still find it.""" tag = uuid.uuid4().hex[:8] @@ -139,7 +139,7 @@ async def test_an_in_scope_rule_is_found_however_many_closer_rules_are_out_of_sc mine_vec = [0.8, 0.6] + [0.0] * (EMBEDDING_DIM - 2) # cosine 0.8 books = [] try: - with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()): + with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()): book = await rulebooks_svc.create_rulebook(reader_id, "Reader's rules") books.append((book.id, reader_id)) topic = await rulebooks_svc.create_topic(book.id, reader_id, "mine") diff --git a/tests/test_integration_rule_surfacing.py b/tests/test_integration_rule_surfacing.py index f85fd749..e502bcf7 100644 --- a/tests/test_integration_rule_surfacing.py +++ b/tests/test_integration_rule_surfacing.py @@ -10,21 +10,18 @@ planning read carries — and what mocks can't prove about it: comparison, never a similarity score. 2. An UNTAGGED global rule is not listed: it applies everywhere and arrives by retrieval, and listing every one under every project would say nothing. -3. A `co_surfaces` partner arrives with its other half — the failure that made - merging rule 144 into rule 46 look like the only fix — unless the partner - lives on a different project, because an edge is not a way in. - -Milestone 414 dropped the suppression claim ("an explicit suppression outranks -an edge") with suppressions themselves. +3. A `co_surfaces` partner arrives with its other half — without it, merging + two rules that must arrive together looks like the only fix — unless the + partner lives on a different project, because an edge is not a way in. """ import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.project import Project -from scribe.services import canonical_systems as canonical_svc -from scribe.services import rulebooks as rulebooks_svc -from scribe.services import systems as systems_svc +from grimoire.models import async_session +from grimoire.models.project import Project +from grimoire.services import canonical_systems as canonical_svc +from grimoire.services import rulebooks as rulebooks_svc +from grimoire.services import systems as systems_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -102,9 +99,9 @@ async def test_a_tagged_global_rule_is_listed_for_a_project_that_works_in_its_ar @pytest.mark.integration async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world): - """Rule 144 was split off rule 46 and folded back the same day because - "either rule could surface without the other". This is the edge that makes - that unnecessary: the partner arrives even though nothing else selected + """Two rules that must arrive together get folded into one because either + could surface without the other. This is the edge that makes that + unnecessary: the partner arrives even though nothing else selected it, and says why it is here.""" partner = await rulebooks_svc.create_rule( world["topic"], world["owner"], "Version names are labels", diff --git a/tests/test_integration_rule_verification.py b/tests/test_integration_rule_verification.py index 2c3e01ea..61cfbf6f 100644 --- a/tests/test_integration_rule_verification.py +++ b/tests/test_integration_rule_verification.py @@ -1,4 +1,4 @@ -"""Real-Postgres tests for a rule's CHECK — the write half (milestone 312). +"""Real-Postgres tests for a rule's CHECK — the write half. What mocks cannot prove, and what the staleness sweep depends on: @@ -20,9 +20,9 @@ from datetime import datetime, timezone import pytest import pytest_asyncio -from scribe.models import async_session -from scribe.models.rulebook import Rule -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.rulebook import Rule +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user, rule_row pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -132,7 +132,7 @@ async def test_editing_anything_else_leaves_the_stamp_alone(constraint): assert row.why.startswith("act_runner picks the shell") -# ── the sweep itself (step 3) ────────────────────────────────────────── +# ── the sweep itself ─────────────────────────────────────────────────── @pytest_asyncio.fixture async def rulebook_of_three(): diff --git a/tests/test_integration_rule_versions.py b/tests/test_integration_rule_versions.py index 9b405335..0af8a3ba 100644 --- a/tests/test_integration_rule_versions.py +++ b/tests/test_integration_rule_versions.py @@ -1,12 +1,12 @@ -"""Real-Postgres tests for the rule-history write path (milestone 323 step 2). +"""Real-Postgres tests for the rule-history write path. Every assertion here is about ORDERING or ABSENCE, which is why none of them can be made with mocks. What a version is for: it holds what the rule USED TO SAY. The edit destroys -that, and nothing can recompute it — the rescoping of rule 79 had to be -hand-copied into a task log to survive (#3237), which is not a process, it is -a person remembering. +that, and nothing can recompute it — without a version, a rescoped rule's old +wording survives only if a person copies it somewhere, which is not a +process. Two of these pin a DELIBERATE ABSENCE. `note_versions` carries a 300-second interval gate and a 50-version cap, both defences against note autosave. Rules @@ -19,11 +19,11 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.rulebook import Rule, Rulebook -from scribe.models.rule_version import RuleVersion -from scribe.services import rule_versions as rv_svc -from scribe.services import rulebooks as rulebooks_svc +from grimoire.models import async_session +from grimoire.models.rulebook import Rule, Rulebook +from grimoire.models.rule_version import RuleVersion +from grimoire.services import rule_versions as rv_svc +from grimoire.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -199,9 +199,9 @@ async def test_history_reads_newest_first(constraint): ] -# ── The read path is ACL-scoped (milestone 323 step 3) ───────────────── +# ── The read path is ACL-scoped ─────────────────────────────────────── # -# Rule 47: every read of user data is scoped by owner. A version carries a +# Every read of user data is scoped by access. A version carries a # `user_id`, which makes it tempting to scope the history by it — that would # be wrong in both directions, and these pin which way round it goes. diff --git a/tests/test_integration_search_scope.py b/tests/test_integration_search_scope.py index 7cf04f3d..a96b551a 100644 --- a/tests/test_integration_search_scope.py +++ b/tests/test_integration_search_scope.py @@ -1,14 +1,14 @@ """Real-Postgres crowd tests: an in-scope record is found however many nearer -records sit outside the scope (#4961; the rule search's is #4958's, in +records sit outside the scope (the rule search's crowd test is in test_integration_rule_scope). Ordered straight off a `*_embeddings` table, the planner can walk the HNSW index, which takes ~`hnsw.ef_search` (40) nearest chunks from every owner and project and only then applies the scope. Each test here puts 80 out-of-scope -records nearer the query than the reader's one in-scope record, so the old -shape could not reach it when the index served the order. What a test cannot -force is the plan — the shape guard in test_search_scope_shape is what fails -on the old query whatever Postgres picks. +records nearer the query than the reader's one in-scope record, so a query +that scopes after the index walk cannot reach it when the index serves the +order. What a test cannot force is the plan — the shape guard in +test_search_scope_shape is what fails on that query whatever Postgres picks. Rows are inserted directly with hand-made vectors and the embedder is stubbed, so the scope alone decides what comes back. @@ -20,17 +20,17 @@ from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import delete -from scribe.models import async_session -from scribe.models.embedding import ( +from grimoire.models import async_session +from grimoire.models.embedding import ( EMBEDDING_DIM, MilestoneEmbedding, NoteEmbedding, SystemEmbedding, ) -from scribe.models.milestone import Milestone -from scribe.models.note import Note -from scribe.models.project import Project -from scribe.models.system import System -from scribe.models.user import User -from scribe.services import embeddings as emb -from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL +from grimoire.models.milestone import Milestone +from grimoire.models.note import Note +from grimoire.models.project import Project +from grimoire.models.system import System +from grimoire.models.user import User +from grimoire.services import embeddings as emb +from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")] @@ -57,11 +57,11 @@ def _chunk(model, key: str, record_id: int, vec: list[float], **extra): async def _found(search, user_id: int, **scope) -> set[int]: """The ids a search finds. Every search fails open, so a query that raised reads as an empty result — the swallowed traceback is captured and named - in the failure instead (#4958).""" + in the failure instead.""" raised: list[str] = [] - with patch("scribe.services.embeddings.get_embedding", + with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)), \ - patch("scribe.services.embeddings.logger") as log: + patch("grimoire.services.embeddings.logger") as log: log.warning.side_effect = lambda *_a, **_k: raised.append(traceback.format_exc()) hits = await search(user_id, "anything", limit=10, threshold=0.5, **scope) assert not raised, "the search raised:\n" + "\n".join(raised) diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index 314f44cd..2411be31 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -1,4 +1,4 @@ -"""Real-Postgres integration tests for shape classification (#2789). +"""Real-Postgres integration tests for shape classification. What mocks can't prove: the all-or-nothing batch against real rows, the write-ACL gate, the todo query's filters, and the consumer map riding @@ -9,11 +9,11 @@ import pytest import pytest_asyncio from sqlalchemy import select -from scribe.models import async_session -from scribe.models.code_shape import CodeShape -from scribe.models.project import Project -from scribe.models.user import User -from scribe.services.shape_ledger import ( +from grimoire.models import async_session +from grimoire.models.code_shape import CodeShape +from grimoire.models.project import Project +from grimoire.models.user import User +from grimoire.services.shape_ledger import ( classify_shapes, classify_shapes_where, list_project_shapes, @@ -36,7 +36,7 @@ SHAPES = [ @pytest_asyncio.fixture async def seeded(): """Owner + outsider, a project with a synced 4-shape ledger, one snippet.""" - from scribe.services import snippets as snippets_svc + from grimoire.services import snippets as snippets_svc async with async_session() as s: owner = await ensure_user(s, "classify_owner") @@ -96,7 +96,7 @@ async def test_classify_applies_judgments_and_reports_unmatched(seeded): @pytest.mark.integration async def test_a_bad_batch_applies_nothing(seeded): - """All-or-nothing (#2709's lesson): a caller must never learn later that + """All-or-nothing: a caller must never learn later that half a batch silently happened.""" owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] with pytest.raises(ValueError) as err: @@ -131,7 +131,7 @@ async def test_classification_is_write_gated_and_listing_read_gated(seeded): @pytest.mark.integration async def test_rule_form_sweeps_unclassified_rows_only_and_applies_whole(seeded): - """#2868: one judgment over a directory + glob; judged rows are left + """One judgment over a directory + glob; judged rows are left alone unless include_judged; the same gates as the row form.""" owner, other, pid, sid = seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] await classify_shapes(owner, pid, [ @@ -171,10 +171,10 @@ async def test_rule_form_sweeps_unclassified_rows_only_and_applies_whole(seeded) @pytest.mark.integration async def test_sync_stamps_scoped_rows_and_unstamps_when_they_become_reachable(seeded): - """#2869: by-construction one-offs arrive `scoped` (mechanical), count as + """By-construction one-offs arrive `scoped` (mechanical), count as accounted, are reached by the sweep's default, and go back to the todo if a later tree makes them ordinary. A judgment overrides the stamp.""" - from scribe.services.coverage import ArchiveShape + from grimoire.services.coverage import ArchiveShape owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] scoped_shapes = [ ArchiveShape("web/Card.vue", "css", "card", ".card {", "s1", ".card { x: 1 }", True), @@ -203,10 +203,10 @@ async def test_sync_stamps_scoped_rows_and_unstamps_when_they_become_reachable(s @pytest.mark.integration async def test_uses_edges_are_the_consumer_map(seeded): - """#2870: a shape keeps ONE snippet_id (what it is) and any number of + """A shape keeps ONE snippet_id (what it is) and any number of uses edges (what it calls); the snippet's consumer map lists them, list_shapes(uses=N) finds them, and a sweep can write them.""" - from scribe.services import snippets as snippets_svc + from grimoire.services import snippets as snippets_svc owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] helper = await snippets_svc.create_snippet( owner, name="cls_hash_helper", code="def hash_token(raw):\n return raw\n", @@ -232,7 +232,7 @@ async def test_uses_edges_are_the_consumer_map(seeded): assert (await list_project_shapes(owner, pid, uses=hid))[1] == 2 # The proposer writes uses edges for JUDGED rows too: Config (exempt) # names hash_token in its body → an edge, no proposal. - from scribe.services.shape_ledger import propose_for_repo + from grimoire.services.shape_ledger import propose_for_repo await classify_shapes(owner, pid, [ {"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "settings"}, ]) @@ -270,8 +270,8 @@ async def test_list_filters_compose(seeded): @pytest.mark.integration async def test_get_snippet_carries_the_structured_consumer_map(seeded): - from scribe.mcp._context import _user_id_ctx - from scribe.mcp.tools.snippets import get_snippet + from grimoire.mcp._context import _user_id_ctx + from grimoire.mcp.tools.snippets import get_snippet owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] await classify_shapes(owner, pid, [ @@ -296,14 +296,14 @@ async def test_get_snippet_carries_the_structured_consumer_map(seeded): @pytest.mark.integration async def test_sync_refiles_rows_whose_snippet_was_purged(seeded): - """The SET NULL companion (#2787): a judgment whose target is hard-deleted + """The SET NULL companion: a judgment whose target is hard-deleted rejoins the todo on the next sync instead of dangling target-less.""" owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] await classify_shapes(owner, pid, [ {"path": "src/app.py", "symbol": "make_app", "status": "instance", "snippet_id": sid}, ]) - from scribe.models.note import Note + from grimoire.models.note import Note async with async_session() as s: note = await s.get(Note, sid) @@ -318,12 +318,12 @@ async def test_sync_refiles_rows_whose_snippet_was_purged(seeded): assert row.snippet_id is None -# --- #2791: the write-path feed lands hook evidence as rows ------------------- +# --- the write-path feed lands hook evidence as rows -------------------------- @pytest.mark.integration async def test_write_path_evidence_is_a_proposal_never_a_verdict(seeded): - """Milestone 439. Pulled + referenced → every named shape of the + """Pulled + referenced → every named shape of the snippet's kind CARRIES the snippet as a proposal (basis "reference"), and its status is untouched: the agent that wrote it says what it is. A judgment is never touched, not even by a proposal. The outsider @@ -331,7 +331,7 @@ async def test_write_path_evidence_is_a_proposal_never_a_verdict(seeded): edge — a fact about the code — is still recorded.""" from datetime import datetime, timezone - from scribe.services.shape_ledger import suggest_write_path_instances + from grimoire.services.shape_ledger import suggest_write_path_instances owner, other, pid, sid = ( seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] @@ -396,7 +396,7 @@ async def test_a_brand_new_shape_gets_a_provisional_row_carrying_the_suggestion( carry a suggestion.""" from datetime import datetime, timezone - from scribe.services.shape_ledger import suggest_write_path_instances, unjudged_shapes + from grimoire.services.shape_ledger import suggest_write_path_instances, unjudged_shapes owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] pulled = {sid: datetime.now(timezone.utc)} @@ -446,8 +446,8 @@ async def test_recent_pulls_reads_the_usage_stream(seeded): window; a surfacing alone is not a pull.""" from datetime import datetime, timedelta, timezone - from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent - from scribe.services.shape_ledger import recent_pulls + from grimoire.models.note_usage import PULLED, SURFACED, NoteUsageEvent + from grimoire.services.shape_ledger import recent_pulls owner, sid = seeded["owner"], seeded["snippet"] now = datetime.now(timezone.utc) @@ -465,7 +465,7 @@ async def test_recent_pulls_reads_the_usage_stream(seeded): assert sid + 2000 not in pulls -# --- #2792: the mechanical proposer against real rows ------------------------- +# --- the mechanical proposer against real rows ------------------------------- def _quiet_semantic(): @@ -473,7 +473,7 @@ def _quiet_semantic(): bases and the bookkeeping, so it answers "nothing" here.""" from unittest.mock import AsyncMock, patch - from scribe.services import shape_ledger + from grimoire.services import shape_ledger return patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None)) @@ -494,7 +494,7 @@ async def test_proposer_proposes_and_confirm_classifies(seeded): nothing for the unrelated — skips rows whose content it already judged, and a scoped confirm turns proposals into agent instances while a classify on another retires its proposal.""" - from scribe.services.shape_ledger import ( + from grimoire.services.shape_ledger import ( confirm_proposals, propose_for_repo, ) @@ -564,7 +564,7 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): """Shapes with no canon hit that repeat — identical bodies in two files, the same name in three — carry a derive proposal, and the readout ranks the families. A canon proposal keeps a row out of any derive group.""" - from scribe.services.shape_ledger import ( + from grimoire.services.shape_ledger import ( apply_derive_groups, live_rows, propose_for_repo, proposal_summary, ) @@ -594,7 +594,7 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): summary = proposal_summary(await live_rows(pid)) assert summary["proposed"] == 1 - # #2872: the body-identical group (a real copy) outranks the bigger name + # The body-identical group (a real copy) outranks the bigger name # group (usually convention), even at size 2 vs 3. order = [g["group"] for g in summary["derive_groups"]] assert order[0].startswith("dup:") and order[1] == "name:css:card" @@ -605,7 +605,7 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): # One of the css copies gets judged → the group shrinks on the next pass # but stays a family: a class in two files is already a recipe living in - # two places (css name floor 2, note 2917). Judge the second and it's gone. + # two places (css name floor 2). Judge the second and it's gone. await classify_shapes(owner, pid, [ {"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"}, ]) @@ -623,11 +623,11 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): @pytest.mark.integration async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded): - """#2900: against real rows — a name in a family → the family (other - files, count; for CSS a NAME family, never a body one — note 2917); a + """Against real rows — a name in a family → the family (other files, + count; for CSS a NAME family, never a body one); a name whose canonical row lives elsewhere → that canon; a judged row at the path, the canon's own file, or an unknown name → silence.""" - from scribe.services.shape_ledger import apply_derive_groups, write_time_derive + from grimoire.services.shape_ledger import apply_derive_groups, write_time_derive owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] defs = _defs( @@ -667,12 +667,12 @@ async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded @pytest.mark.integration async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(seeded): - """#2899: the first sync seeds one `slug`; a later sync adds an identical + """The first sync seeds one `slug`; a later sync adds an identical copy. Against the stamp between them, derive_new counts ONLY the newcomer — the drift since the last refresh, not the whole family.""" from datetime import datetime, timezone - from scribe.services.shape_ledger import ( + from grimoire.services.shape_ledger import ( apply_derive_groups, derive_new_summary, live_rows, ) @@ -700,12 +700,12 @@ async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(se @pytest.mark.integration async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded): - """#2970 end to end: the two class forms a template never spells out — + """End to end: the two class forms a template never spells out — a transition `name=` and a concatenated name — travel from the real extractor through resolution into the flag, so rules they reach stop being reported as unused. Only a rule nothing can reach stays listed.""" - from scribe.services.coverage import class_references - from scribe.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows + from grimoire.services.coverage import class_references + from grimoire.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows pid, owner = seeded["pid"], seeded["owner"] defs = _defs( @@ -752,11 +752,11 @@ async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded @pytest.mark.integration async def test_consumer_map_syncs_edges_from_template_references(seeded): - """Milestone 302: the consumer edges follow the archive — own-file + """The consumer edges follow the archive — own-file resolution for a scoped class, fan-out to the shared sheet for a class a template does not define, counts refreshed and stale edges removed on the next sync, and a vanished row's edges gone with it.""" - from scribe.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers + from grimoire.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers pid = seeded["pid"] defs = _defs( @@ -791,7 +791,7 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded): # The readout side: used_by per css row, the unused-css flag, and the # family's consumers on the write-path check. - from scribe.services.shape_ledger import ( + from grimoire.services.shape_ledger import ( apply_derive_groups, used_by_map, write_time_derive, ) owner = seeded["owner"] @@ -812,7 +812,7 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded): assert rows[("v/B.vue", "error-msg")] not in edges -# --- #2793: the divergence readout against real rows ------------------------- +# --- the divergence readout against real rows ------------------------------- @pytest.mark.integration @@ -825,15 +825,15 @@ async def test_a_second_confirm_dialog_is_detected_and_named(seeded): judgment clears the flag; a shape proposed AS the canon is not flagged.""" from datetime import datetime, timedelta, timezone - from scribe.services.shape_ledger import ( + from grimoire.services.shape_ledger import ( divergence_summary, flag_divergence, live_rows, propose_for_repo, write_time_divergence, ) - from scribe.services import snippets as snippets_svc + from grimoire.services import snippets as snippets_svc owner, pid = seeded["owner"], seeded["pid"] - # The confirm helper is TS canon: since #2871 a sym basis only proposes + # The confirm helper is TS canon: a sym basis only proposes # within the shape's language family, so the fixture's Python snippet # says nothing about these Vue bodies — the canon must be one of theirs. canon = await snippets_svc.create_snippet( @@ -906,10 +906,9 @@ async def test_a_second_confirm_dialog_is_detected_and_named(seeded): @pytest.mark.integration async def test_weak_stamps_elect_no_canon_and_their_flags_are_withdrawn(seeded): - """#4608, the poisoned-ledger case as it stood on two projects: a - directory's "dominant canon" was elected entirely by hook stamps written - at 0.68-0.72, before the 0.80 floor, so every new shape there was told it - diverged from a YAML CI snippet. The same fixture as the acceptance case + """The poisoned-ledger case: a directory's "dominant canon" elected + entirely by hook stamps written below the 0.80 floor would tell every new + shape there it diverged from an unrelated snippet. The same fixture as the acceptance case above, with one difference — the four instances are weak hook stamps rather than an audit — and the prompt must fall silent: at refresh (the standing flag is withdrawn) and at write time. The stamps themselves are @@ -918,8 +917,8 @@ async def test_weak_stamps_elect_no_canon_and_their_flags_are_withdrawn(seeded): from sqlalchemy import update - from scribe.services import snippets as snippets_svc - from scribe.services.shape_ledger import ( + from grimoire.services import snippets as snippets_svc + from grimoire.services.shape_ledger import ( _RESEMBLE_REASON, flag_divergence, propose_for_repo, write_time_divergence, ) @@ -984,14 +983,14 @@ async def test_weak_stamps_elect_no_canon_and_their_flags_are_withdrawn(seeded): @pytest.mark.integration async def test_a_semantic_miss_does_not_silence_the_prompt(seeded): - """#4208, reversed on measurement: a semantic miss is not evidence. + """A semantic miss is not evidence. THE FIXTURE IS THE ACCEPTANCE CASE ABOVE, DELIBERATELY. A hand-rolled `confirmDanger` beside an async confirm canon is structurally identical to a registry helper beside an async service canon, and the arm's miss was - meant to tell them apart. Measured live, true instances of a canon rarely - clear the arm's floor either, so a miss fires on both — silencing on - it silenced #2793's own case. Here the arm has read the body, found + meant to tell them apart. True instances of a canon rarely clear the arm's + floor either, so a miss fires on both — silencing on it would silence the + case the readout exists for. Here the arm has read the body, found nothing, and the prompt is still RAISED. Asserted on the row, not a flag count: `since` sits a second early to @@ -1000,9 +999,9 @@ async def test_a_semantic_miss_does_not_silence_the_prompt(seeded): from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, patch - from scribe.services import shape_ledger - from scribe.services import snippets as snippets_svc - from scribe.services.shape_ledger import flag_divergence, live_rows, propose_for_repo + from grimoire.services import shape_ledger + from grimoire.services import snippets as snippets_svc + from grimoire.services.shape_ledger import flag_divergence, live_rows, propose_for_repo owner, pid = seeded["owner"], seeded["pid"] canon = await snippets_svc.create_snippet( @@ -1052,7 +1051,7 @@ async def _two_generations(seeded, tag: str): """ from datetime import datetime, timezone - from scribe.services import snippets as snippets_svc + from grimoire.services import snippets as snippets_svc owner, pid = seeded["owner"], seeded["pid"] canon = await snippets_svc.create_snippet( @@ -1087,14 +1086,14 @@ async def _two_generations(seeded, tag: str): @pytest.mark.integration async def test_the_capped_semantic_pass_reaches_the_new_shape_first(seeded): - """Measured live: after the #4208 version bump, 1,533 rows competed for 150 - semantic checks in row order and the six new shapes — the only ones the - divergence check acts on — were all left unread and flagged. With room for + """After a version bump, every row competes for a capped number of + semantic checks; in row order the new shapes — the only ones the + divergence check acts on — are left unread and flagged. With room for ONE check, the new shape must be the one that gets it.""" from unittest.mock import AsyncMock, patch - from scribe.services import shape_ledger - from scribe.services.shape_ledger import live_rows, propose_for_repo + from grimoire.services import shape_ledger + from grimoire.services.shape_ledger import live_rows, propose_for_repo owner, pid, later, _previous = await _two_generations(seeded, "cap") with patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None)): @@ -1109,7 +1108,7 @@ async def test_the_capped_semantic_pass_reaches_the_new_shape_first(seeded): @pytest.mark.integration async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(seeded): - from scribe.services.shape_ledger import shape_history + from grimoire.services.shape_ledger import shape_history owner, other, pid, sid = ( seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] @@ -1155,7 +1154,7 @@ async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(s assert await shape_history(other, pid, "src/app.py") == {} -# --- milestone 439: the writer judges what it wrote, before the sync has seen it +# --- the writer judges what it wrote, before the sync has seen it @pytest.mark.integration async def test_a_shape_written_this_turn_is_judged_before_the_sync_sees_it(seeded): @@ -1164,7 +1163,7 @@ async def test_a_shape_written_this_turn_is_judged_before_the_sync_sees_it(seede then reads the shape keeps the judgment, and a sync that does NOT have it yet (the code has not reached the bound ref) keeps it through the vanish and the reappearance. Without `repo` the old contract holds.""" - from scribe.services import repo_bindings as repo_bindings_svc + from grimoire.services import repo_bindings as repo_bindings_svc owner, pid = seeded["owner"], seeded["pid"] await repo_bindings_svc.set_binding(owner, REPO, pid) @@ -1201,7 +1200,7 @@ async def test_unjudged_is_what_nobody_read(seeded): audit's verdict is not. Evidence travels; a verdict is never inferred.""" from sqlalchemy import update - from scribe.services.shape_ledger import _RESEMBLE_REASON, unjudged_shapes + from grimoire.services.shape_ledger import _RESEMBLE_REASON, unjudged_shapes owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] await classify_shapes(owner, pid, [ @@ -1235,13 +1234,13 @@ async def test_unjudged_is_what_nobody_read(seeded): @pytest.mark.integration async def test_a_whole_file_snippet_makes_its_file_row_canonical(seeded): - """Milestone 439: a snippet recorded at a component's path, with no + """A snippet recorded at a component's path, with no symbol, is the canon for that file row — the claim a whole-file record makes and a definition row never could. It still covers no definition inside the file.""" - from scribe.services import snippets as snippets_svc - from scribe.services.coverage import _recorded_locations - from scribe.services.shape_ledger import mark_canonicals + from grimoire.services import snippets as snippets_svc + from grimoire.services.coverage import _recorded_locations + from grimoire.services.shape_ledger import mark_canonicals owner, pid = seeded["owner"], seeded["pid"] await sync_repo_shapes(pid, REPO, SHAPES + [ diff --git a/tests/test_integration_snippet_locations.py b/tests/test_integration_snippet_locations.py index 7f5a3638..17608226 100644 --- a/tests/test_integration_snippet_locations.py +++ b/tests/test_integration_snippet_locations.py @@ -1,4 +1,4 @@ -"""Real-Postgres integration test for the snippet location reverse lookup (#2083). +"""Real-Postgres integration test for the snippet location reverse lookup. Runs only in the CI integration lane (real Postgres, schema built by `alembic upgrade head`, which includes migration 0070's `notes.data` + GIN index). @@ -19,11 +19,11 @@ import pytest import pytest_asyncio from sqlalchemy import delete, func, select -from scribe.models import async_session -from scribe.models.note import Note -from scribe.models.user import User -from scribe.services.knowledge import location_matches, location_parts -from scribe.services.snippets import ( +from grimoire.models import async_session +from grimoire.models.note import Note +from grimoire.models.user import User +from grimoire.services.knowledge import location_matches, location_parts +from grimoire.services.snippets import ( SNIPPET_NOTE_TYPE, backfill_snippet_data, compose_body, @@ -57,13 +57,13 @@ async def seeded(): data=compose_data(name=name, language="python", locations=locations), ) - nested = _snippet("nested", [loc(repo="Scribe", path="frontend/src/lib/x.ts", symbol="helper")]) - sibling = _snippet("sibling", [loc(repo="Scribe", path="frontend/srcmap.ts", symbol="other")]) - # Two locations, deliberately crossed: repo Scribe at src/a.py and repo - # Portal at src/b.py. repo=Scribe + path=src/b.py must NOT match it. + nested = _snippet("nested", [loc(repo="Grimoire", path="frontend/src/lib/x.ts", symbol="helper")]) + sibling = _snippet("sibling", [loc(repo="Grimoire", path="frontend/srcmap.ts", symbol="other")]) + # Two locations, deliberately crossed: repo Grimoire at src/a.py and repo + # Portal at src/b.py. repo=Grimoire + path=src/b.py must NOT match it. multi = _snippet( "multi", - [loc(repo="Scribe", path="src/a.py", symbol="alpha"), loc(repo="Portal", path="src/b.py", symbol="beta")], + [loc(repo="Grimoire", path="src/a.py", symbol="alpha"), loc(repo="Portal", path="src/b.py", symbol="beta")], ) # No structured location at all — must never satisfy a location filter, # and must not error the query either. @@ -101,7 +101,7 @@ async def _ids_for(user_id: int, **parts) -> set[int]: @pytest.mark.asyncio async def test_repo_filter_finds_every_snippet_recorded_in_it(seeded): user_id, ids = seeded - found = await _ids_for(user_id, repo="Scribe") + found = await _ids_for(user_id, repo="Grimoire") assert found == {ids["nested"], ids["sibling"], ids["multi"]} assert ids["placeless"] not in found @@ -124,9 +124,9 @@ async def test_path_prefix_reaches_into_subdirectories(seeded): @pytest.mark.asyncio async def test_parts_must_match_within_one_location(seeded): user_id, ids = seeded - assert await _ids_for(user_id, repo="Scribe", path="src/a.py") == {ids["multi"]} + assert await _ids_for(user_id, repo="Grimoire", path="src/a.py") == {ids["multi"]} # Same snippet, but repo and path belong to two different entries. - assert await _ids_for(user_id, repo="Scribe", path="src/b.py") == set() + assert await _ids_for(user_id, repo="Grimoire", path="src/b.py") == set() assert await _ids_for(user_id, repo="Portal", path="src/b.py") == {ids["multi"]} @@ -152,10 +152,10 @@ async def test_keyword_arm_applies_the_location_filter(seeded): keyword (ILIKE) arm, which is the second place the SQL clause has to appear.""" user_id, ids = seeded with patch( - "scribe.services.embeddings.semantic_search_notes", + "grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[]), ): - assert await _ids_for(user_id, q="nested", repo="Scribe") == {ids["nested"]} + assert await _ids_for(user_id, q="nested", repo="Grimoire") == {ids["nested"]} # Matches the query but not the place. assert await _ids_for(user_id, q="nested", repo="Portal") == set() @@ -180,7 +180,7 @@ async def test_semantic_arm_applies_the_location_filter(seeded): candidates = [(0.9, n) for n in rows] with patch( - "scribe.services.embeddings.semantic_search_notes", + "grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=candidates), ): # No keyword can match this, so every hit below came from the stub. @@ -190,7 +190,7 @@ async def test_semantic_arm_applies_the_location_filter(seeded): assert await _ids_for(user_id, q="zzzznomatch", path="frontend/src") == { ids["nested"], } - assert await _ids_for(user_id, q="zzzznomatch", repo="Scribe", path="src/b.py") == set() + assert await _ids_for(user_id, q="zzzznomatch", repo="Grimoire", path="src/b.py") == set() @pytest.mark.asyncio @@ -212,13 +212,13 @@ async def test_sql_and_python_dialects_agree_on_the_same_rows(seeded): by_id = {n.id: n.data for n in rows} cases = [ - {"repo": "Scribe"}, + {"repo": "Grimoire"}, {"path": "frontend/src"}, {"path": "frontend/src/lib/x.ts"}, - {"repo": "Scribe", "path": "src/a.py"}, - {"repo": "Scribe", "path": "src/b.py"}, + {"repo": "Grimoire", "path": "src/a.py"}, + {"repo": "Grimoire", "path": "src/b.py"}, {"symbol": "beta"}, - {"repo": "Scribe", "path": "frontend", "symbol": "helper"}, + {"repo": "Grimoire", "path": "frontend", "symbol": "helper"}, ] for case in cases: sql_ids = await _ids_for(user_id, **case) diff --git a/tests/test_integration_task_kind_spike.py b/tests/test_integration_task_kind_spike.py index 467c437d..f3bd0fe3 100644 --- a/tests/test_integration_task_kind_spike.py +++ b/tests/test_integration_task_kind_spike.py @@ -1,6 +1,6 @@ """Real-Postgres test that the CHECK actually accepts 'spike' (0091). -Rule 36 exists because the value and the constraint can drift apart: the +The value and the constraint can drift apart: the code starts writing a new kind while the database still refuses it, and nothing catches it until a write fails in front of someone. A mock cannot show that — it has no CHECK — so the constraint gets its own real-DB test, @@ -14,8 +14,8 @@ import pytest import pytest_asyncio from sqlalchemy.exc import IntegrityError -from scribe.models import async_session -from scribe.models.note import Note +from grimoire.models import async_session +from grimoire.models.note import Note from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @@ -68,21 +68,20 @@ async def test_an_unknown_kind_is_still_refused(owner_id): Without this, every assertion above would pass just as happily against a table whose CHECK had been dropped and never re-added — which is exactly - the failure rule 36 is written against. + the drift this file is written against. """ with pytest.raises(IntegrityError): await _write(owner_id, "investigation") async def test_a_kind_can_be_corrected_after_the_fact(owner_id): - """The write-once bug, asserted against a real column (#3129). + """A kind is not write-once, asserted against a real column. The read-back is the whole test. A version that only asserted the update - call succeeded would have passed against the broken code — the route - returned 200 while dropping the field, which is exactly how this survived - long enough to be found by hand. + call succeeded would pass against a route that returns 200 while dropping + the field, which is a failure nothing else reports. """ - from scribe.services import notes as notes_svc + from grimoire.services import notes as notes_svc note_id = await _write(owner_id, "work") await notes_svc.update_note(owner_id, note_id, task_kind="spike") @@ -92,7 +91,7 @@ async def test_a_kind_can_be_corrected_after_the_fact(owner_id): async def test_an_invalid_kind_is_refused_before_the_database(owner_id): """A readable ValueError, not a CheckViolationError surfacing as a 500.""" - from scribe.services import notes as notes_svc + from grimoire.services import notes as notes_svc note_id = await _write(owner_id, "work") with pytest.raises(ValueError, match="Invalid kind"): diff --git a/tests/test_knowledge_facets.py b/tests/test_knowledge_facets.py index 80fd2038..47c17485 100644 --- a/tests/test_knowledge_facets.py +++ b/tests/test_knowledge_facets.py @@ -3,18 +3,18 @@ The facet predicate is written twice by necessity — as SQL for rows the database hands back, and as Python for candidates the vector search has already fetched — plus a third time as the `is_task` pre-filter that narrows -the candidate set before it exists. Those three used to be hand-written and -agreed only by luck: adding `issue` to the SQL arm alone would have set the -pre-filter to is_task=False, handed the Python arm a candidate set with no -tasks in it, and returned an empty semantic half for the Issues facet forever -with nothing red anywhere (#3128). +the candidate set before it exists. Hand-written, those three +agree only by luck: adding `issue` to the SQL arm alone would set the +pre-filter to is_task=False, hand the Python arm a candidate set with no +tasks in it, and return an empty semantic half for the Issues facet forever +with nothing red anywhere. -They are now generated from one table. These tests pin the property that made -the trap possible, so it cannot come back by a different route. +So they are generated from one table, and these tests pin the property that +makes the trap possible, so it cannot come back by a different route. """ import pytest -from scribe.services.knowledge import ( +from grimoire.services.knowledge import ( _FACETS, FACET_TYPES, NON_TASK_FACETS, @@ -28,7 +28,7 @@ from tests.helpers import compiled_sql, fake_note, fake_snippet, fake_task def _sql(note_type): from sqlalchemy import select - from scribe.models.note import Note + from grimoire.models.note import Note return compiled_sql(_apply_type_filter(select(Note.id), note_type)) @@ -104,8 +104,7 @@ def test_the_live_task_kinds_are_all_facets(): def test_non_task_facets_are_the_note_types_and_only_those(): """Spelled out rather than derived, so adding a kind to `_FACETS` has to - be a deliberate edit in two places. `lesson` joined in milestone 385 step - 2 (#3729) and is non-task on purpose: `is_task` IS `status is not None`, + be a deliberate edit in two places. `lesson` is non-task on purpose: `is_task` IS `status is not None`, so a lesson that acquired a status would stop being a lesson.""" assert set(NON_TASK_FACETS) == {"note", "process", "snippet", "lesson"} diff --git a/tests/test_ledger_references_not_silence.py b/tests/test_ledger_references_not_silence.py index a190a195..9cd482ac 100644 --- a/tests/test_ledger_references_not_silence.py +++ b/tests/test_ledger_references_not_silence.py @@ -1,22 +1,20 @@ -"""A repeat on a note arm is referenced, not withheld (#4101). +"""A repeat on a note arm is referenced, not withheld. WHY THIS EXISTS -#3750 settled the argument for rules: a record the session was told about an -hour ago is not a record in front of the reader now, so the second time it is +A record the session was told about an hour ago is not a record in front of the reader now, so the second time it is the best answer the session gets the line again with a tail saying so — never silence, which is indistinguishable from "nothing matched". -The note and snippet arms never got that fix. Worse, their ledger went into -`semantic_search_notes` as `exclude_ids`, so the repeat left the candidate set -entirely, which had three consequences: +The note and snippet arms hold the same line. A ledger passed into +`semantic_search_notes` as `exclude_ids` takes the repeat out of the candidate +set entirely, with three consequences: - - the session got silence the second time, on the arms that fire most; - - a compaction made it permanent, because the ledger outlived the context it - described (the same step fixes that one layer down); - - and `best_available` was measured against a candidate set the caller had - already edited, so the bar could be blamed for a record the caller withheld - (#3739, from the side its fix never reached). + - the session gets silence the second time, on the arms that fire most; + - a compaction makes it permanent, because the ledger outlives the context it + describes; + - and `best_available` is measured against a candidate set the caller has + already edited, so the bar can be blamed for a record the caller withheld. WHAT THIS PINS @@ -28,11 +26,11 @@ WHAT THIS PINS and a dev-log borrowing it would claim authority it does not have — so what is pinned is that the line appears and is distinguishable, not its prose. - 3. The telemetry splits the two (#3752 / #3668): the row's result set and the + 3. The telemetry splits the two: the row's result set and the surfaced table both take FRESH only, and the repeat is COUNTED in `suppressed` instead. This is what makes a zero-result call readable — `result_count == 0` with `suppressed_count > 0` says "everything that - matched, the session has already seen", which was unreportable here. + matched, the session has already seen". 4. The deliberate exception: the write-path SYNC class still shows once. Its claim is about an edit in progress, not about a record's continuing relevance, and repeating it would be nagging rather than reminding. @@ -69,7 +67,7 @@ def _snippet_item(nid, title, user_id=1): # ── auto-inject ──────────────────────────────────────────────────────────── async def _autoinject(hits, exclude_ids, *, rec=None, surf=None): - from scribe.services import plugin_context as pc + from grimoire.services import plugin_context as pc search = AsyncMock(return_value=hits) with patch.object(pc, "get_autoinject_config", AsyncMock( return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \ @@ -123,11 +121,11 @@ async def test_the_repeat_is_counted_not_reported_as_a_result(): @pytest.mark.asyncio async def test_a_call_where_everything_was_already_seen_is_readable(): - """The fact that could not be expressed before. + """A call where every hit was already seen is not a call nothing cleared. - Previously this call logged zero results with no suppression count, so it - was indistinguishable from a bar nothing cleared — and the operator tuning - that bar would have been reading the wrong number. + Logged as zero results with no suppression count, it would be + indistinguishable from a bar nothing cleared — and the operator tuning + that bar would be reading the wrong number. """ rec = MagicMock() hits = [(0.80, fake_note(id=11, title="Pool sizing decision", user_id=1))] @@ -141,7 +139,7 @@ async def test_a_call_where_everything_was_already_seen_is_readable(): @pytest.mark.asyncio async def test_a_repeat_is_not_recorded_as_a_fresh_surfacing(): - """#3668's identity: this table and the log row describe the same call.""" + """The two-table identity: this table and the log row describe the same call.""" surf = MagicMock() hits = [(0.80, fake_note(id=11, title="Pool sizing decision", user_id=1)), (0.78, fake_note(id=22, title="run_maintenance thresholds", user_id=1))] @@ -152,8 +150,9 @@ async def test_a_repeat_is_not_recorded_as_a_fresh_surfacing(): @pytest.mark.asyncio -async def test_the_header_no_longer_promises_once_per_session(): - """A contract stated in the prose is a contract, and this one changed. +async def test_the_header_explains_the_seen_marker(): + """A contract stated in the prose is a contract: the header explains the + `seen` marker and does not promise a record once per session. Cheap to forget and invisible when wrong: the menu would carry a marker the header had never explained, and a reader would have to guess. @@ -168,7 +167,7 @@ async def test_the_header_no_longer_promises_once_per_session(): # ── the write path ───────────────────────────────────────────────────────── async def _write_path(hits, exclude_ids, *, here=(), rec=None, sync_exclude=()): - from scribe.services import plugin_context as pc + from grimoire.services import plugin_context as pc search = AsyncMock(return_value=hits) listing = AsyncMock(side_effect=[(list(here), len(here)), ([], 0)]) with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_wp_cfg())), \ diff --git a/tests/test_lesson_claim.py b/tests/test_lesson_claim.py index 73e093b0..b68f36a1 100644 --- a/tests/test_lesson_claim.py +++ b/tests/test_lesson_claim.py @@ -1,8 +1,8 @@ -"""A lesson's name is one claim on one line (#4797). +"""A lesson's name is one claim on one line. `what` is the title every listing and menu prints. Written as the incident -instead of the claim, it turned a menu line into 1,416 characters with the -claim somewhere in the middle. Two halves are pinned: the doors refuse a new +instead of the claim, it turns a menu line into a paragraph with the claim +somewhere in the middle. Two halves are pinned: the doors refuse a new one, and the menu shows one line for a name stored before they did. """ from __future__ import annotations @@ -13,10 +13,10 @@ from unittest.mock import AsyncMock, patch import pytest -from scribe.mcp._context import _user_id_ctx -from scribe.mcp.tools.lessons import create_lesson, update_lesson -from scribe.services import lessons as lessons_svc -from scribe.services.lessons import WHAT_MAX_CHARS, claim_line, require_claim +from grimoire.mcp._context import _user_id_ctx +from grimoire.mcp.tools.lessons import create_lesson, update_lesson +from grimoire.services import lessons as lessons_svc +from grimoire.services.lessons import WHAT_MAX_CHARS, claim_line, require_claim TRIGGER = "an existing call is about to double as a liveness signal" CLAIM = ("A check-in piggybacked on an existing call inherits every backoff " @@ -80,8 +80,8 @@ async def test_a_lesson_stored_with_a_long_name_can_still_be_edited(): ) updated = AsyncMock(return_value=note) with patch.object(lessons_svc, "update_lesson", updated), \ - patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()), \ - patch("scribe.mcp.tools.lessons.lesson_rules_svc.attach_lesson_rules", AsyncMock()): + patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()), \ + patch("grimoire.mcp.tools.lessons.lesson_rules_svc.attach_lesson_rules", AsyncMock()): await update_lesson(lesson_id=1, when_to_apply=TRIGGER) assert updated.await_args.kwargs["what"] is None @@ -90,7 +90,7 @@ async def test_a_lesson_stored_with_a_long_name_can_still_be_edited(): def test_the_rest_door_refuses_too(handler): """The web form writes lessons as well, and a bound one door enforces is a bound the other door walks around.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(getattr(routes, handler)) write = "lessons_svc." + handler.removesuffix("_route") + "(" @@ -118,7 +118,7 @@ def test_a_first_sentence_past_the_bound_is_cut_at_a_word(): def test_the_menu_names_a_long_lesson_in_one_line(): - from scribe.services.plugin_context import _menu_name + from grimoire.services.plugin_context import _menu_name name = _menu_name(STORY, "lesson", {"what": STORY, "when_to_apply": TRIGGER}, "") assert name.endswith(" …") diff --git a/tests/test_lesson_convergence.py b/tests/test_lesson_convergence.py index a718ab3c..048d8d91 100644 --- a/tests/test_lesson_convergence.py +++ b/tests/test_lesson_convergence.py @@ -1,4 +1,4 @@ -"""Convergence named at the write (milestone 440, #4634). +"""Convergence named at the write. When a lesson is answered "no rule fits", the response looks for other no-rule lessons in the same situation and, once there are enough, names the @@ -13,10 +13,10 @@ from unittest.mock import AsyncMock, patch import pytest -from scribe.mcp._context import _user_id_ctx -from scribe.mcp.tools import lessons as lesson_tools -from scribe.services import lesson_rules as links_svc -from scribe.services import lessons as lessons_svc +from grimoire.mcp._context import _user_id_ctx +from grimoire.mcp.tools import lessons as lesson_tools +from grimoire.services import lesson_rules as links_svc +from grimoire.services import lessons as lessons_svc # Bound before conftest's autouse stub replaces the module attribute. _REAL = links_svc.convergence_for @@ -53,7 +53,7 @@ def test_one_incident_written_up_three_times_is_not_a_recurring_situation(): assert links_svc.convergence_group(same) is None -# ── The bar is relative to each lesson's own register (#5193) ────────────── +# ── The bar is relative to each lesson's own register ────────────────────── def test_too_little_background_says_nothing_stands_out(): @@ -122,7 +122,7 @@ def _search_over(sim=_sim): def _run(no_rule, sim=_sim): return ( patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_NOTES[1])), - patch("scribe.services.embeddings.semantic_search_notes", _search_over(sim)), + patch("grimoire.services.embeddings.semantic_search_notes", _search_over(sim)), patch.object(links_svc, "_no_rule_ids", AsyncMock(side_effect=lambda ids: {i for i in ids if i in no_rule})), ) @@ -138,7 +138,7 @@ async def test_lessons_that_stand_out_together_are_named(): @pytest.mark.asyncio async def test_a_large_no_rule_pool_in_one_flat_band_names_nothing(): - """The #5193 failure: every lesson answered "no rule", every score above + """The flat-band failure: every lesson answered "no rule", every score above a fixed bar, and a group the size of the pool. Nothing stands out of a flat band, so nothing is named however large the pool grows.""" a, b, c = _run(no_rule=set(_NOTES), sim=lambda x, y: 0.66 + 0.01 * ((x + y) % 5)) diff --git a/tests/test_lesson_document_shape.py b/tests/test_lesson_document_shape.py index 21af5def..30622f1d 100644 --- a/tests/test_lesson_document_shape.py +++ b/tests/test_lesson_document_shape.py @@ -1,4 +1,4 @@ -"""The document a lesson is embedded as (milestone 385 step 3; milestone 427). +"""The document a lesson is embedded as. WHY THIS IS THE STEP THAT DECIDES THE MILESTONE @@ -9,17 +9,16 @@ anywhere would report that. WHERE THE SHARP SHAPE LIVES -A snippet, which note #2485 measured as the only sharp record in the corpus (a -0.153 top-to-second gap against 0.010–0.023 for everything else), is sharp +A snippet, measured as the sharpest record kind in a corpus (a 0.153 +top-to-second gap against 0.010–0.023 for everything else), is sharp because its document states its purpose twice: `name — when to use` as the title, and again as the body's first line. A lesson follows the snippet. -Until milestone 427 that `subject — trigger` title was also the STORED title, -so every listing, menu and search row showed the trigger too — kilobytes of it. -Now the stored title is the subject, and `embeddings.document_title` joins the -trigger back from `data` at embed time. The embedded TEXT is what it always -was, which is the property these guards pin: nothing re-embeds, and the floors -tuned against these vectors stay calibrated. +The STORED title is the subject alone, so listings, menus and search rows do +not carry kilobytes of trigger; `embeddings.document_title` joins the trigger +back from `data` at embed time. The embedded TEXT keeps the `subject — +trigger` shape, which is the property these guards pin: the floors tuned +against these vectors stay calibrated. No similarity number is asserted anywhere: a threshold pins the embedder's behaviour rather than this code's, and breaks on a model change that is not a @@ -27,8 +26,8 @@ regression. """ from __future__ import annotations -from scribe.services import lessons as lessons_svc -from scribe.services.embeddings import chunk_document, document_title, embedding_text +from grimoire.services import lessons as lessons_svc +from grimoire.services.embeddings import chunk_document, document_title, embedding_text TRIGGER = "a test fails on code you believe is correct" SUBJECT = "Suspect the guard before the code" @@ -44,7 +43,7 @@ def _embedded(what: str, trigger: str, insight: str) -> tuple[str, str]: def test_the_stored_title_is_the_subject_alone(): - """Milestone 427. The trigger lives in `data` and the body's first line; the + """The trigger lives in `data` and the body's first line; the title a listing shows is what the lesson is ABOUT.""" title, _ = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT) assert title == SUBJECT diff --git a/tests/test_lesson_insight_round_trip.py b/tests/test_lesson_insight_round_trip.py index dd6bde43..f08c6c07 100644 --- a/tests/test_lesson_insight_round_trip.py +++ b/tests/test_lesson_insight_round_trip.py @@ -1,4 +1,4 @@ -"""Reading a lesson's insight back and re-composing it returns the same body (#4272). +"""Reading a lesson's insight back and re-composing it returns the same body. THE PROPERTY. `update_lesson` is field-wise: an edit that touches only `system_ids`, or only `what`, passes `insight=None`, and the service then @@ -15,20 +15,18 @@ worth having wherever a record is stored composed and edited in parts: not "does the strip remove the marker" — it did — but "does an edit that changes nothing change nothing". -WHAT WENT WRONG. The derivation used to filter out body LINES matching the -trigger marker's pattern, which is `re.M` and therefore matches one line. -`when_to_apply` is free text and is routinely several paragraphs; the -composer writes all of it after the marker, so paragraphs 2..n were ordinary -lines that the filter had no reason to touch. They came back as "the -insight", the full trigger was prepended again, and the stored lesson gained -(n-1) paragraphs per update — silently, compounding, and in the half of the +WHAT GOES WRONG. A derivation that filters out body LINES matching the +trigger marker's pattern, which is `re.M` and therefore matches one line, +leaves the rest of the trigger behind. `when_to_apply` is free text and is +routinely several paragraphs; the composer writes all of it after the marker, +so paragraphs 2..n are ordinary lines that the filter has no reason to touch. +They come back as "the insight", the full trigger is prepended again, and the +stored lesson gains (n-1) paragraphs per update — silently, compounding, and in the half of the document that does the retrieving. -Measured on the one real row this damaged (lesson #4207, a three-paragraph -trigger): the body grew by exactly 607 characters per edit — the length of -paragraphs 2 and 3 with their separators — taking it from 1291 characters to -7361 over ten edits, at which point the trigger appears eleven times and the -insight is 17% of the record. +On a three-paragraph trigger the body grows by the length of paragraphs 2 and +3 with their separators on every edit — after ten edits the trigger appears +eleven times and the insight is a sixth of the record. WHY A MARKER COULD NOT HAVE FIXED IT. There is no end-of-trigger marker in the body and adding one would not repair a row already written without it. @@ -47,7 +45,7 @@ from __future__ import annotations import pytest -from scribe.services import lessons as lessons_svc +from grimoire.services import lessons as lessons_svc from tests.helpers import fake_lesson WHAT = "A column written once at create time is a snapshot, not a field" @@ -99,7 +97,7 @@ def _stored(trigger: str, insight: str, sources: list[int]): MagicMock auto-creates truthy and coerces to `1`. A no-sources shape then recomposes with `**Learned from:** #1` and the round trip fails for a reason that has nothing to do with the trigger. `fake_lesson` pins that - field to None — note #2109's point, which is why the fixture exists. + field to None, which is why the fixture exists. """ title, body = lessons_svc.lesson_document(WHAT, trigger, insight, sources) data = lessons_svc.compose_data(WHAT, trigger, sources) @@ -137,7 +135,7 @@ def test_an_edit_that_changes_nothing_changes_nothing(_name, trigger, insight, s # Reported as a size delta rather than left to a diff of two multi-kilobyte # strings: the failure this catches is duplication, and the number of # characters gained says which paragraph came back far faster than an - # equality dump does (rule 167 — a guard has to name what broke). + # equality dump does (a guard has to name what broke). assert (title, body) == (note.title, note.body), ( f"a no-op edit rewrote the lesson: title {len(title) - len(note.title):+d} " f"chars, body {len(body) - len(note.body):+d} chars" @@ -182,7 +180,7 @@ def test_the_insight_is_derived_from_the_row_not_from_the_body_alone(): """Why the function takes a note. The body on its own does not say where the trigger ends — that is the - information the old shape did not have and guessed at. The mirror in + information a line-wise filter does not have and guesses at. The mirror in `data` is where it comes from, so a caller holding only the body string cannot be served, and this pins that the argument is the record.""" note = _stored(MANY_PARAGRAPHS, INSIGHT, []) diff --git a/tests/test_lesson_kind.py b/tests/test_lesson_kind.py index 7753af7e..3319d190 100644 --- a/tests/test_lesson_kind.py +++ b/tests/test_lesson_kind.py @@ -1,18 +1,17 @@ """The `lesson` kind — its trigger, its title, and its place in the vocabulary. -WHY THIS EXISTS (milestone 385 step 2, #3729) +WHY THIS EXISTS A lesson is a note that must be findable by WHEN IT APPLIES rather than by what it is about. That is a document-shape fact: the trigger has to reach the -embedded text, and decision #4157 put it in `notes.data` with a mirror in the -title and the head of the body — the shape snippets already use. +embedded text, so it lives in `notes.data` with a mirror in the title and the +head of the body — the shape snippets use. THE ONE THAT MATTERS MOST `test_one_join_builds_every_trigger_title`. Three kinds now rank on a -`{subject} — {trigger}` title: rules, snippets and lessons. That join had three -implementations before this step and would have had four; #3207 records what -that costs. The guard is behavioural rather than `assert a is b`, because the +`{subject} — {trigger}` title: rules, snippets and lessons, through ONE join — +a copy per kind is a fix that has to be found in three places. The guard is behavioural rather than `assert a is b`, because the three are reached through different public names and a test that compared identities would pass on a re-implementation that merely re-exported. @@ -27,9 +26,9 @@ and goes red the day one is added without this value. """ from types import SimpleNamespace -from scribe.services import knowledge as knowledge_svc -from scribe.services import lessons as lessons_svc -from scribe.services.embeddings import trigger_title +from grimoire.services import knowledge as knowledge_svc +from grimoire.services import lessons as lessons_svc +from grimoire.services.embeddings import trigger_title def _note(data=None, body=""): @@ -75,9 +74,9 @@ def test_one_join_builds_every_trigger_title(): expected = f"{subject} — {trigger}" assert trigger_title(subject, trigger) == expected - # The two note kinds reach it through the EMBEDDED title (milestone 427), + # The two note kinds reach it through the EMBEDDED title, # from their own mirror key — never a hand-rolled join. - from scribe.services.embeddings import document_title + from grimoire.services.embeddings import document_title assert document_title(subject, "lesson", {"when_to_apply": trigger}) == expected assert document_title(subject, "snippet", {"when_to_use": trigger}) == expected @@ -95,7 +94,7 @@ def test_the_kind_is_in_the_browse_vocabulary(): Asserted through the public table rather than a literal, because the door's validation, the counts and both dialects of the type filter are all - generated from it (#3161) — so this is the one place that decides. + generated from it — so this is the one place that decides. """ assert lessons_svc.LESSON_NOTE_TYPE in knowledge_svc.FACET_TYPES assert lessons_svc.LESSON_NOTE_TYPE in knowledge_svc.NON_TASK_FACETS @@ -104,17 +103,17 @@ def test_the_kind_is_in_the_browse_vocabulary(): def test_the_global_kinds_list_names_the_lesson_and_nothing_else(): """`embeddings.GLOBAL_NOTE_TYPES` spells "lesson" as a literal because `services.lessons` imports `trigger_title` from that module, so importing - it back would close a cycle (milestone 385 step 3). The copy is pinned + it back would close a cycle. The copy is pinned here instead — the one thing a literal costs is that it can drift, and this is what stops it. """ - from scribe.services.embeddings import GLOBAL_NOTE_TYPES + from grimoire.services.embeddings import GLOBAL_NOTE_TYPES assert GLOBAL_NOTE_TYPES == (lessons_svc.LESSON_NOTE_TYPE,) def test_a_lesson_is_not_a_task_on_either_arm_of_the_filter(): - """The cell left empty on purpose (#3163). + """The cell left empty on purpose. `is_task` IS `status is not None`, so a lesson that acquired a status would become a task and start appearing in open-work listings — the one way diff --git a/tests/test_lesson_rest_door.py b/tests/test_lesson_rest_door.py index f2f83013..5e7b5c35 100644 --- a/tests/test_lesson_rest_door.py +++ b/tests/test_lesson_rest_door.py @@ -1,11 +1,11 @@ -"""The REST door for lessons — the half the web UI can actually reach (#3734). +"""The REST door for lessons — the half the web UI can actually reach. WHY THIS EXISTS AT ALL -Milestone 385 built the lesson kind through the MCP tools, which is the surface -an agent uses. The Vue app speaks REST, so until this blueprint existed a lesson -was a record a person could not create, read, edit or retire from the UI — rule -27's "no UI, no ship" failing at the door rather than in the view. +The MCP tools are the surface an agent uses; the Vue app speaks REST. Without +this blueprint a lesson is a record a person cannot create, read, edit or +retire from the UI — a feature with no UI, failing at the door rather than in +the view. WHAT THESE PIN @@ -17,7 +17,7 @@ kind: own title would produce lessons that rank differently from the agent's, and the document IS what ranks. - - ACL (rule 78). Share-aware resolve, write as the owner — the pattern + - ACL. Share-aware resolve, write as the owner — the pattern routes/snippets.py sets — so a shared editor isn't rejected by the owner-scoped service. @@ -43,17 +43,17 @@ import pytest def test_both_doors_share_one_serializer(): """A payload shape spelled once per door answers the two of them differently the first time a field is added.""" - from scribe.mcp.tools import lessons as mcp_lessons - from scribe.services import lessons as lessons_svc + from grimoire.mcp.tools import lessons as mcp_lessons + from grimoire.services import lessons as lessons_svc assert mcp_lessons._to_dict is lessons_svc.lesson_to_dict def test_the_rest_door_composes_nothing_itself(): - """Asserted on structure (rule 167). The document is what ranks, so a door + """Asserted on structure. The document is what ranks, so a door that built its own title would produce lessons that rank differently from the ones the agent writes.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes) # It may CALL the service's composer (the dedup gate needs the document), @@ -67,15 +67,15 @@ def test_the_rest_door_composes_nothing_itself(): def test_the_door_is_registered(): """A blueprint nobody registers is a file, not a door.""" - from scribe import app as app_module + from grimoire import app as app_module src = inspect.getsource(app_module) - assert "from scribe.routes.lessons import lessons_bp" in src + assert "from grimoire.routes.lessons import lessons_bp" in src assert "app.register_blueprint(lessons_bp)" in src def test_the_blueprint_is_mounted_where_the_client_looks(): - from scribe.routes.lessons import lessons_bp + from grimoire.routes.lessons import lessons_bp assert lessons_bp.url_prefix == "/api/lessons" @@ -84,7 +84,7 @@ def test_the_reverse_lookup_is_registered_before_the_id_route(): """Quart matches in registration order. The int converter protects `taught-by` today, but the ordering is what keeps that true if the converter is ever widened — the same care snippets' `/duplicates` takes.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes) assert src.index('"/taught-by/"') < src.index( @@ -98,7 +98,7 @@ def test_the_reverse_lookup_is_registered_before_the_id_route(): def test_create_refuses_a_lesson_with_no_trigger(): """The kind-specific guard. A triggerless lesson saves and never surfaces, and nothing about the stored record shows it.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes.create_lesson_route) assert "when_to_apply is required" in src @@ -111,20 +111,20 @@ def test_create_refuses_a_lesson_with_no_trigger(): def test_update_refuses_to_clear_the_trigger(): """The other half. Creating without one is refused; emptying one later would reach the same broken state by a different path.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes.update_lesson_route) assert "cannot be cleared" in src def test_the_subject_is_required_too(): - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes.create_lesson_route) assert "what is required" in src -# ── ACL: rule 78's pattern, not a bare owner filter ────────────────────────── +# ── ACL: the shared-access pattern, not a bare owner filter ────────────────── @pytest.mark.parametrize( @@ -133,22 +133,22 @@ def test_the_subject_is_required_too(): def test_writes_check_permission_and_act_as_the_owner(handler): """Resolve share-aware, then write as the owner — otherwise a shared editor is rejected by the owner-scoped service.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(getattr(routes, handler)) assert "can_write_note" in src, f"{handler} does not check write permission" assert "note.user_id" in src, ( f"{handler} writes as the caller rather than as the owner, which " - f"rejects a legitimately shared editor (rule 78)" + f"rejects a legitimately shared editor" ) def test_no_handler_builds_its_own_owner_filter(): - """Rule 78's actual failure mode: a route assembling its own + """The access layer's actual failure mode: a route assembling its own `Note.user_id == uid` clause instead of going through the service and the access helpers. Passing `user_id=uid` INTO a service is the correct call and is not what this looks for.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes) assert "Note.user_id" not in src @@ -159,7 +159,7 @@ def test_no_handler_builds_its_own_owner_filter(): def test_delete_trashes_recoverably(): """Every kind's delete is a trash, and the batch id is what restores it.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes.delete_lesson_route) assert "trash_svc.delete" in src @@ -172,7 +172,7 @@ def test_delete_trashes_recoverably(): def test_the_reverse_lookup_reads_the_indexed_mirror(): """Not by scanning bodies: `data[taught_by]` is JSONB with a GIN index (0070), which is the whole reason step 4 put the list there.""" - from scribe.services import lessons as lessons_svc + from grimoire.services import lessons as lessons_svc src = inspect.getsource(lessons_svc.lessons_taught_by) assert "path_exists" in src @@ -182,7 +182,7 @@ def test_the_reverse_lookup_reads_the_indexed_mirror(): def test_the_reverse_lookup_is_share_aware(): """It renders beside a record the caller can already see, so a lesson someone shared with them belongs in the list exactly as their own does.""" - from scribe.services import lessons as lessons_svc + from grimoire.services import lessons as lessons_svc src = inspect.getsource(lessons_svc.lessons_taught_by) assert "readable_notes_clause" in src @@ -192,7 +192,7 @@ def test_the_reverse_lookup_is_share_aware(): def test_the_reverse_lookup_refuses_a_nonsense_id_rather_than_interpolating(): """The jsonpath is built by formatting, so the id has to be an int before it gets near the expression.""" - from scribe.services import lessons as lessons_svc + from grimoire.services import lessons as lessons_svc src = inspect.getsource(lessons_svc.lessons_taught_by) assert "int(record_id)" in src @@ -200,7 +200,7 @@ def test_the_reverse_lookup_refuses_a_nonsense_id_rather_than_interpolating(): @pytest.mark.asyncio async def test_a_bad_id_returns_nothing_and_raises_nothing(): - from scribe.services.lessons import lessons_taught_by + from grimoire.services.lessons import lessons_taught_by assert await lessons_taught_by(1, 0) == [] assert await lessons_taught_by(1, -3) == [] @@ -214,7 +214,7 @@ def test_the_payload_reads_back_the_composed_fields(): """A caller that wrote `when_to_apply` reads `when_to_apply` back, not a body it has to parse.""" from tests.helpers import fake_lesson - from scribe.services.lessons import compose_body, lesson_to_dict + from grimoire.services.lessons import compose_body, lesson_to_dict what = "Read the job log before waiting longer" trigger = "a CI run has sat in_progress longer than its suite takes" @@ -238,8 +238,8 @@ def test_the_payload_reads_back_the_composed_fields(): def test_the_rest_guards_can_fail(): - """Rule 167: shown turning red once.""" - from scribe.routes import lessons as routes + """Shown turning red once.""" + from grimoire.routes import lessons as routes src = inspect.getsource(routes) assert "a phrase that is definitely not in this module" not in src diff --git a/tests/test_lesson_rule_link_doors.py b/tests/test_lesson_rule_link_doors.py index 96ec27a2..a5a9c7f8 100644 --- a/tests/test_lesson_rule_link_doors.py +++ b/tests/test_lesson_rule_link_doors.py @@ -1,4 +1,4 @@ -"""The lesson → rule link at its doors (milestone 440 step 1, #4630). +"""The lesson → rule link at its doors. The link's own behaviour — states, cascade, ACL — is tested against Postgres in tests/test_integration_lesson_rule_links.py. These pin the wiring a mock CAN @@ -16,12 +16,12 @@ from unittest.mock import AsyncMock, patch import pytest -from scribe.mcp._context import _user_id_ctx -from scribe.mcp.tools import lessons as lesson_tools -from scribe.models.lesson_rule_link import LINK_STATES -from scribe.services import backup -from scribe.services import lesson_rules as links_svc -from scribe.services import lessons as lessons_svc +from grimoire.mcp._context import _user_id_ctx +from grimoire.mcp.tools import lessons as lesson_tools +from grimoire.models.lesson_rule_link import LINK_STATES +from grimoire.services import backup +from grimoire.services import lesson_rules as links_svc +from grimoire.services import lessons as lessons_svc TRIGGER = "a CI run is still in_progress far past its usual time" @@ -39,9 +39,9 @@ def _stub_note(**kw): def _create_patches(created): return ( patch.object(lessons_svc, "create_lesson", created), - patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note", + patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), - patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()), + patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()), ) @@ -112,7 +112,7 @@ def test_verdicts_name_real_states(): def test_the_migration_check_and_the_model_agree_on_the_states(): - """Rule 36's drift, guarded: the CHECK is written from the migration's + """Model/migration drift, guarded: the CHECK is written from the migration's tuple and the code from the model's, so they must be the same tuple.""" path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0111_lesson_rule_links.py" spec = importlib.util.spec_from_file_location("m0111", path) @@ -145,7 +145,7 @@ def test_backup_keeps_an_unjudged_link_unjudged(): assert built.judged_at is None -# ── #4631: judged when written ─────────────────────────────────────────────── +# ── judged when written ────────────────────────────────────────────────────── @pytest.mark.asyncio @@ -237,8 +237,8 @@ async def test_list_unjudged_is_a_listing_and_routes_to_its_own_query(): _user_id_ctx.set(7) listed = AsyncMock(return_value=([], 0)) with patch.object(links_svc, "list_unjudged", listed), \ - patch("scribe.mcp.tools.lessons.access_svc.label_shared_items", AsyncMock(return_value=[])), \ - patch("scribe.mcp.tools.lessons.attach_usage", AsyncMock()): + patch("grimoire.mcp.tools.lessons.access_svc.label_shared_items", AsyncMock(return_value=[])), \ + patch("grimoire.mcp.tools.lessons.attach_usage", AsyncMock()): out = await lesson_tools.list_lessons(unjudged=True, tag="ci", project_id=2) assert out == {"lessons": [], "total": 0} assert listed.await_args.kwargs["tag"] == "ci" diff --git a/tests/test_lesson_rule_link_ui.py b/tests/test_lesson_rule_link_ui.py index e3c2949f..522d7717 100644 --- a/tests/test_lesson_rule_link_ui.py +++ b/tests/test_lesson_rule_link_ui.py @@ -1,4 +1,4 @@ -"""Both records show the lesson → rule link in the web UI (milestone 440, #4635). +"""Both records show the lesson → rule link in the web UI. The frontend has no component test runner, so these read the source — and each is written against something the backend owns, so a drift on either @@ -20,9 +20,9 @@ import inspect import re from pathlib import Path -from scribe.models.lesson_rule_link import LINK_STATES -from scribe.services import lesson_rules as links_svc -from scribe.services.access import PERMISSION_RANK +from grimoire.models.lesson_rule_link import LINK_STATES +from grimoire.services import lesson_rules as links_svc +from grimoire.services.access import PERMISSION_RANK FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src" @@ -122,7 +122,7 @@ def test_no_page_spells_its_own_write_check(): assert offenders == [] -# ── #4658: the web editor records the answer ───────────────────────────────── +# ── the web editor records the answer ──────────────────────────────────────── def _payload_fn(view: str) -> str: @@ -131,9 +131,9 @@ def _payload_fn(view: str) -> str: def test_the_editor_sends_the_answer_under_the_names_both_routes_read(): - """Rule 33's contract check: a field the form sends under another name is + """The contract check: a field the form sends under another name is dropped without an error, and the lesson saves unjudged.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes body = _payload_fn(_read("views/LessonEditorView.vue")) assert "rule_ids:" in body and "no_rule:" in body @@ -157,7 +157,7 @@ def test_leaving_it_open_after_linking_clears_the_links_rather_than_nothing(): def test_the_candidates_route_is_where_the_client_asks_and_above_the_id_route(): - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes) assert src.index('"/rule-candidates"') < src.index('"/"') @@ -168,7 +168,7 @@ def test_the_candidates_route_keeps_unavailable_apart_from_none(): """The service returns None when the search could not run. The route has to pass that through as null, not coerce it to an empty list, because "nothing resembles it" is a claim the editor shows to the writer.""" - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes.lesson_rule_candidates_route) assert 'jsonify({"candidates": candidates})' in src diff --git a/tests/test_lesson_rule_soft_links.py b/tests/test_lesson_rule_soft_links.py index 5dc0c650..56fe8039 100644 --- a/tests/test_lesson_rule_soft_links.py +++ b/tests/test_lesson_rule_soft_links.py @@ -1,4 +1,4 @@ -"""Soft links — the counting rules (milestone 440, #4637). +"""Soft links — the counting rules. What a suggested link's evidence counts is a design decision with a failure mode on each side: count every arrival and two similar records "prove" a @@ -12,7 +12,7 @@ from __future__ import annotations import inspect from datetime import datetime, timedelta, timezone -from scribe.services import lesson_rules as links_svc +from grimoire.services import lesson_rules as links_svc NOW = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc) @@ -26,7 +26,7 @@ def test_the_same_ask_reworded_trivially_is_one_situation(): def test_a_file_is_one_situation_however_its_code_differs(): """The write arm keys on the path, so a run of edits to one file — each with different code — is one situation, not one per edit.""" - path = "src/scribe/services/lesson_rules.py" + path = "src/grimoire/services/lesson_rules.py" assert links_svc.situation_key("w", path) == links_svc.situation_key("w", path) assert links_svc.situation_key("w", path) != links_svc.situation_key("w", "src/other.py") @@ -94,8 +94,8 @@ def test_both_hook_doors_count_the_pair_on_their_own_arm(): """The two responses that carry lessons and rules together. A door that renders both and does not record the pair is the miss this step exists to close, and it would produce no error anywhere — only an empty table.""" - from scribe.routes import plugin as plugin_routes - from scribe.services import plugin_context + from grimoire.routes import plugin as plugin_routes + from grimoire.services import plugin_context prompt_door = inspect.getsource(plugin_routes.autoinject_retrieve) assert "co_surfaced(" in prompt_door and 'arm="p"' in prompt_door diff --git a/tests/test_lesson_surfacing.py b/tests/test_lesson_surfacing.py index 05f27b1b..0b1f0482 100644 --- a/tests/test_lesson_surfacing.py +++ b/tests/test_lesson_surfacing.py @@ -1,11 +1,11 @@ -"""A lesson reaches the session it applies to — milestone 385, step 5. +"""A lesson reaches the session it applies to. Three decisions are guarded here, and each has a way of going quietly wrong that these tests are shaped to catch rather than to describe: WHICH ARM. The two note arms, and no new one. An arm that FILTERS kinds does not merely outrank a kind it omits — it makes it unreachable, and nothing - reports an arm that never had the candidate (#3702). The write path filters; + reports an arm that never had the candidate. The write path filters; the prompt menu does not, but is project-scoped, which for a kind whose whole claim is that it transfers amounts to the same silence. @@ -20,8 +20,8 @@ that these tests are shaped to catch rather than to describe: from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.services import plugin_context as pc -from scribe.services.lessons import LESSON_NOTE_TYPE +from grimoire.services import plugin_context as pc +from grimoire.services.lessons import LESSON_NOTE_TYPE from tests.helpers import fake_lesson, fake_note, writepath_cfg pytestmark = pytest.mark.usefixtures("_no_supersession") @@ -115,8 +115,8 @@ async def test_the_write_path_asks_for_lessons_at_all(): async def test_a_lesson_takes_a_reserved_slot_and_the_query_is_logged(): """The slot is a claim that a kind deserves a guaranteed line. A claim like that has to be falsifiable from the first deploy, which means its own query - in `retrieval_logs` — #2463's finding, recorded about the slot that shipped - without one.""" + in `retrieval_logs`; a slot without one cannot be judged against what it + displaced.""" rec = MagicMock() out, calls = await _menu( [(0.70, fake_note(id=1, title="Badge layout task", user_id=1, is_task=True))], @@ -143,7 +143,7 @@ async def test_the_reserved_lesson_extends_and_never_evicts(): """It sides with `preference_slot` over `reuse_slot`, and the reason is the ledger rather than taste: an evicted hit was RETURNED by the general search and sits in that call's log row, so displacing it makes the two tables - disagree about one call for a reason nothing in the data explains (#3668). + disagree about one call for a reason nothing in the data explains. The voice argument points the same way — a record that binds nothing should not be able to throw a better-scoring one off the menu.""" main = [(0.72, fake_note(id=1, title="a", user_id=1)), @@ -190,7 +190,7 @@ async def test_a_weak_lesson_does_not_buy_the_slot(): @pytest.mark.asyncio async def test_the_slot_surfacing_matches_its_own_retrieval_row(): - """#3668's identity, on the new arm: what a source says it retrieved and + """The two-table identity, on this arm: what a source says it retrieved and what it says it showed must be the same records. The reserved lesson is fetched by `lesson_slot`'s query, so it is `lesson_slot`'s surfacing — counting it under `auto_inject` as well would book one delivery twice and @@ -275,7 +275,7 @@ async def test_the_write_path_names_the_register_for_a_lesson_too(): def test_the_surfacing_guards_can_fail(): - """Rule 167: each assertion above has to be able to bite. The three shapes + """Each assertion above has to be able to bite. The three shapes it would take to break this feature silently, each checked here against the condition the tests actually assert on.""" # An arm that stops asking for the kind. diff --git a/tests/test_lesson_usage_surface.py b/tests/test_lesson_usage_surface.py index 0b4c3a97..10566ddf 100644 --- a/tests/test_lesson_usage_surface.py +++ b/tests/test_lesson_usage_surface.py @@ -1,21 +1,18 @@ -"""A lesson's surfaced-vs-opened counts reach a reader (#4196). +"""A lesson's surfaced-vs-opened counts reach a reader. -The lesson slot has recorded usage since it shipped, and until this nothing -showed it. `get_lesson`'s REST door attached it; the list did not, neither MCP -door did, and no view rendered it — so the one kind whose central question is -"is this trigger firing on the right situation" was the one kind whose answer -was unreadable. +Every door — the REST list and detail, both MCP doors, and the view — carries +the counts, because the one kind whose central question is "is this trigger +firing on the right situation" needs that answer readable. -WHY THE COUNTS MATTER MORE HERE THAN ON A SNIPPET. #4196 asks whether a lesson -repeatedly followed should become a rule, and names the trap: raw frequency +WHY THE COUNTS MATTER MORE HERE THAN ON A SNIPPET. Whether a lesson repeatedly +followed should become a rule has a trap: raw frequency cannot tell "this should bind" from "this trigger is too broad", which is the commoner reading. Surfaced-AND-opened can. Neither question is answerable by anything that cannot see the numbers, which is why this is the first step and not the threshold. -NO THRESHOLD IS ASSERTED ANYWHERE HERE, deliberately. At the time of writing -the corpus is 10 lessons with 7 recorded surfacings and 3 opens; a promotion -rule fitted to that would be fitting noise, and `UsageBadge` already declines +NO THRESHOLD IS ASSERTED ANYWHERE HERE, deliberately. A promotion rule fitted +to a small corpus would be fitting noise, and `UsageBadge` already declines to render a verdict on fewer than three surfacings for the same reason. """ import inspect @@ -24,9 +21,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.tools import lessons as lesson_tools -from scribe.services import note_usage -from scribe.services.note_usage import empty_usage +from grimoire.mcp.tools import lessons as lesson_tools +from grimoire.services import note_usage +from grimoire.services.note_usage import empty_usage def _rows(): @@ -115,11 +112,10 @@ async def test_get_lesson_reads_the_count_before_recording_its_own_pull(): assert out["usage"]["pull_count"] == 1 -# ── the REST door, on structure (rule 167) ─────────────────────────────────── +# ── the REST door, on structure ────────────────────────────────────────────── # -# MOVED 2026-09-21 (#4230), not weakened. These used to look for -# `usage_for_notes(` and `empty_usage()` in the route body. Both now live in -# ONE seam, `note_usage.attach_usage`, which seven doors share — so the +# `usage_for_notes(` and `empty_usage()` live in ONE seam, +# `note_usage.attach_usage`, which seven doors share — so the # zero-fill and the single aggregate are pinned once, against the seam, in # tests/test_usage_attach_seam.py. What stays HERE is what only this route can # get wrong: that it calls the seam at all, once, and before it records a pull. @@ -132,7 +128,7 @@ async def test_get_lesson_reads_the_count_before_recording_its_own_pull(): def _route_source(name: str) -> str: - from scribe.routes import lessons as routes + from grimoire.routes import lessons as routes src = inspect.getsource(routes) start = src.index(f"async def {name}(") @@ -166,7 +162,7 @@ def test_the_rest_detail_door_also_reads_before_it_records(): def test_these_guards_can_fail(): - """Rule 167: falsify the shape they assert against, so a guard that has + """Falsify the shape they assert against, so a guard that has quietly stopped describing anything cannot pass by describing nothing.""" src = _route_source("list_lessons_route") assert "def list_lessons_route" in src @@ -174,21 +170,21 @@ def test_these_guards_can_fail(): def test_the_detail_view_renders_the_badge_rather_than_respelling_it(): - """Snippet #3460: reach for UsageBadge and pass the kind's own advice; a - view's scoped re-spelling of `.usage-tag` is what that component replaced. + """Reach for UsageBadge and pass the kind's own advice; a view's scoped + re-spelling of `.usage-tag` is what that component exists to prevent. The advice is the kind-specific half. For a lesson it points at the trigger, not at deletion — a lesson nobody opens is usually keyed to a situation nobody is in, which is `update_lesson`'s own words and the - reading #4196 calls the commoner one. + commoner reading. """ view = (Path(__file__).resolve().parents[1] / "frontend/src/views/LessonDetailView.vue").read_text() assert "UsageBadge" in view assert "usage-tag" not in view, "re-spelled the chip instead of reusing it" - # The sentence moved out of this template into the shared table (#4230, - # recorded on #3460). Assert BOTH halves, so the guard cannot pass by the + # The sentence lives in the shared table, not this template. Assert BOTH + # halves, so the guard cannot pass by the # view pointing at an entry that no longer says the right thing. assert "DEAD_WEIGHT_ADVICE.lesson" in view, "the view no longer reads the lesson advice" table = (Path(__file__).resolve().parents[1] diff --git a/tests/test_lesson_write_path.py b/tests/test_lesson_write_path.py index 10322186..8f2e6143 100644 --- a/tests/test_lesson_write_path.py +++ b/tests/test_lesson_write_path.py @@ -1,4 +1,4 @@ -"""Creating a lesson, and tying it to what taught it (milestone 385 step 4). +"""Creating a lesson, and tying it to what taught it. THE TRIGGER IS THE RECORD, so the door refuses a lesson without one. @@ -22,9 +22,9 @@ from unittest.mock import AsyncMock, patch import pytest -from scribe.mcp._context import _user_id_ctx -from scribe.mcp.tools.lessons import create_lesson, update_lesson -from scribe.services import lessons as lessons_svc +from grimoire.mcp._context import _user_id_ctx +from grimoire.mcp.tools.lessons import create_lesson, update_lesson +from grimoire.services import lessons as lessons_svc TRIGGER = "a test fails on code you believe is correct" SUBJECT = "Suspect the guard before the code" @@ -73,9 +73,9 @@ async def test_a_lesson_keeps_every_incident_that_taught_it(): _user_id_ctx.set(7) created = AsyncMock(return_value=_stub_note()) with patch.object(lessons_svc, "create_lesson", created), \ - patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note", + patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()): + patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()): await create_lesson( what=SUBJECT, when_to_apply=TRIGGER, learned_from=[11, 22, 33], ) @@ -101,7 +101,7 @@ async def test_the_duplicate_gate_runs_before_anything_is_created(): hit = SimpleNamespace(id=99, title="already recorded", similarity=0.97, reason="semantic") created = AsyncMock() - with patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note", AsyncMock(return_value=hit)), \ patch.object(lessons_svc, "create_lesson", created): out = await create_lesson(what=SUBJECT, when_to_apply=TRIGGER) @@ -122,10 +122,10 @@ async def test_the_gate_compares_the_composed_document_not_the_raw_fields(): document, and would judge two lessons alike that rank nothing alike.""" _user_id_ctx.set(7) gate = AsyncMock(return_value=None) - with patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note", gate), \ + with patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note", gate), \ patch.object(lessons_svc, "create_lesson", AsyncMock(return_value=_stub_note())), \ - patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()): + patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()): await create_lesson(what=SUBJECT, when_to_apply=TRIGGER, insight="Look.") title, body = gate.await_args.args[1], gate.await_args.args[2] @@ -136,11 +136,11 @@ async def test_the_gate_compares_the_composed_document_not_the_raw_fields(): def test_a_lesson_is_judged_at_the_trigger_dominated_bar(): - """#2518 measured deliberately-parallel siblings at 0.92 on a document that - is mostly prose ABOUT the thing. A lesson's document is that shape, so the + """Deliberately-parallel siblings measure 0.92 on a document that is + mostly prose ABOUT the thing. A lesson's document is that shape, so the bar sits above that band — otherwise two different lessons about one area block each other, which is the failure step 4 asked to check for.""" - from scribe.services.dedup import ( + from grimoire.services.dedup import ( _LESSON_SEMANTIC_THRESHOLD, _SEMANTIC_THRESHOLD, _semantic_threshold, @@ -153,8 +153,8 @@ def test_a_lesson_is_judged_at_the_trigger_dominated_bar(): ) assert _LESSON_SEMANTIC_THRESHOLD > _SEMANTIC_THRESHOLD # The carve-out is per kind, not a loosening of the gate: a lesson's bar is - # its own, apart from the note's copy band (#4306). - from scribe.services.dedup import _NOTE_COPY_THRESHOLD + # its own, apart from the note's copy band. + from grimoire.services.dedup import _NOTE_COPY_THRESHOLD assert _semantic_threshold("note") == _NOTE_COPY_THRESHOLD assert _NOTE_COPY_THRESHOLD != _LESSON_SEMANTIC_THRESHOLD @@ -163,7 +163,7 @@ def test_a_lesson_is_judged_at_the_trigger_dominated_bar(): @pytest.mark.asyncio async def test_an_update_recomposes_both_halves_of_the_document(): """A new trigger has to reach the mirror AND the head of the body — the two - places the embedded document reads it from (milestone 427). Patching one + places the embedded document reads it from. Patching one would leave a lesson that reads correctly and ranks on the old situation — the failure mode with no symptom. The title stays the subject, even when the stored one was an un-migrated composed title.""" @@ -175,7 +175,7 @@ async def test_an_update_recomposes_both_halves_of_the_document(): ) updated = AsyncMock(return_value=_stub_note()) with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=stored)), \ - patch("scribe.services.notes.update_note", updated): + patch("grimoire.services.notes.update_note", updated): await lessons_svc.update_lesson(7, 1, when_to_apply="a guard goes red") fields = updated.await_args.kwargs @@ -198,7 +198,7 @@ async def test_an_update_does_not_stack_the_composed_lines(): ) updated = AsyncMock(return_value=_stub_note()) with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=stored)), \ - patch("scribe.services.notes.update_note", updated): + patch("grimoire.services.notes.update_note", updated): await lessons_svc.update_lesson(7, 1, what="Suspect the guard") body = updated.await_args.kwargs["body"] diff --git a/tests/test_list_rows_brief.py b/tests/test_list_rows_brief.py index faab13e7..1cf21473 100644 --- a/tests/test_list_rows_brief.py +++ b/tests/test_list_rows_brief.py @@ -1,9 +1,9 @@ -"""List tools return rows that say what a record is, never what it says (#4061). +"""List tools return rows that say what a record is, never what it says. -list_tasks once returned every row's to_dict(), body included. A project's todo -list came to 93-165k characters, past what an MCP client accepts as a tool -result, so the list arrived as a file to page through — the failure #4045 fixed -for enter_project, one call over. A list is for choosing which record to open; +A list that returns every row's to_dict(), body included, runs a project's todo +list to 93-165k characters, past what an MCP client accepts as a tool result, +so the list arrives as a file to page through — the same failure enter_project +avoids by carrying a summary. A list is for choosing which record to open; get_task / get_note read one in full. """ import json @@ -13,10 +13,10 @@ from unittest.mock import AsyncMock, patch import pytest -from scribe.mcp.tools.notes import list_notes -from scribe.mcp.tools.systems import list_system_records -from scribe.mcp.tools.tasks import list_tasks -from scribe.services.notes import brief_row +from grimoire.mcp.tools.notes import list_notes +from grimoire.mcp.tools.systems import list_system_records +from grimoire.mcp.tools.tasks import list_tasks +from grimoire.services.notes import brief_row pytestmark = pytest.mark.usefixtures("_bind_user") @@ -41,7 +41,7 @@ def test_a_task_row_names_what_it_is_and_where_it_sits_but_not_its_body(): assert row["milestone_title"] == "An existing plan is found" assert {"id", "title", "status", "task_kind", "priority", "milestone_id", "tags", "updated_at", "project_id"} <= set(row) - # Empty optional fields are left off rather than sent as null (#2483). + # Empty optional fields are left off rather than sent as null. assert not {"description", "parent_id", "due_date"} & set(row) assert row["updated_at"] == WHEN.isoformat() @@ -61,9 +61,9 @@ async def test_a_full_page_of_long_tasks_fits_inline(): """The ceiling. 100 rows (list_tasks' own limit cap) of ~5k-character step plans: as to_dict() rows this was over half a million characters.""" rows = [_row(i) for i in range(100)] - with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", + with patch("grimoire.mcp.tools.tasks.notes_svc.list_notes", AsyncMock(return_value=(rows, 100))), \ - patch("scribe.mcp.tools.tasks.milestones_svc.titles_for", + patch("grimoire.mcp.tools.tasks.milestones_svc.titles_for", AsyncMock(return_value={415: "An existing plan is found"})) as titles: out = await list_tasks(project_id=2, limit=100) size = len(json.dumps(out)) @@ -75,7 +75,7 @@ async def test_a_full_page_of_long_tasks_fits_inline(): @pytest.mark.asyncio async def test_list_notes_rows_leave_the_body_to_get_note(): rows = [_row(i, is_task=False) for i in range(3)] - with patch("scribe.mcp.tools.notes.notes_svc.list_notes", + with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", AsyncMock(return_value=(rows, 3))): out = await list_notes(project_id=2) assert all("body" not in r for r in out["notes"]) @@ -84,9 +84,9 @@ async def test_list_notes_rows_leave_the_body_to_get_note(): @pytest.mark.asyncio async def test_list_system_records_rows_leave_the_body_to_the_getters(): rows = [_row(1), _row(2, is_task=False)] - with patch("scribe.mcp.tools.systems.systems_svc.list_records_for_system", + with patch("grimoire.mcp.tools.systems.systems_svc.list_records_for_system", AsyncMock(return_value=rows)), \ - patch("scribe.mcp.tools.systems.milestones_svc.titles_for", + patch("grimoire.mcp.tools.systems.milestones_svc.titles_for", AsyncMock(return_value={415: "Plan"})): out = await list_system_records(system_id=3) assert [r["id"] for r in out["records"]] == [1, 2] diff --git a/tests/test_matched_passage_surfaces.py b/tests/test_matched_passage_surfaces.py index cf628f8a..3be595a5 100644 --- a/tests/test_matched_passage_surfaces.py +++ b/tests/test_matched_passage_surfaces.py @@ -1,8 +1,8 @@ -"""Every semantic search hands on the passage that matched (#4243, #4250). +"""Every semantic search hands on the passage that matched. Three searches collapse chunk rows to the best one per record, so each of them -KNOWS which passage earned the hit. Each used to drop it, leaving every door to -preview the head of the document instead — a span the search had already scored +KNOWS which passage earned the hit. A search that drops it leaves every door to +preview the head of the document instead — a span the search has already scored lower, with nothing saying so. These pin the mechanism (`report["best_chunk"]` from all three searches) and @@ -14,8 +14,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.services import embeddings as emb -from scribe.services.text import ( +from grimoire.services import embeddings as emb +from grimoire.services.text import ( BODY_OPENING, MATCHED_PASSAGE, excerpt_fields, @@ -26,7 +26,7 @@ from tests.helpers import make_mock_session def _searching(rows): """A patched session whose one query returns `rows` — the shared - make_mock_session (#2834) rather than a third local copy of the + make_mock_session rather than a third local copy of the __aenter__/__aexit__ dance.""" session = make_mock_session() result = MagicMock() diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py index f8eca0a4..a03c7065 100644 --- a/tests/test_mcp_auth.py +++ b/tests/test_mcp_auth.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.auth import resolve_bearer +from grimoire.mcp.auth import resolve_bearer @pytest.mark.asyncio @@ -22,7 +22,7 @@ async def test_resolve_bearer_malformed_header_returns_none(): @pytest.mark.asyncio async def test_resolve_bearer_unknown_token_returns_none(): with patch( - "scribe.mcp.auth.lookup_key", + "grimoire.mcp.auth.lookup_key", AsyncMock(return_value=None), ): assert await resolve_bearer("Bearer fmcp_doesnotexist") is None @@ -34,7 +34,7 @@ async def test_resolve_bearer_valid_token_returns_user_id(): fake_key.user_id = 42 fake_key.scope = "write" with patch( - "scribe.mcp.auth.lookup_key", + "grimoire.mcp.auth.lookup_key", AsyncMock(return_value=fake_key), ): uid, scope = await resolve_bearer("Bearer fmcp_validkey") @@ -48,7 +48,7 @@ async def test_resolve_bearer_calls_lookup_with_stripped_token(): fake_key.user_id = 1 fake_key.scope = "write" mock_lookup = AsyncMock(return_value=fake_key) - with patch("scribe.mcp.auth.lookup_key", mock_lookup): + with patch("grimoire.mcp.auth.lookup_key", mock_lookup): await resolve_bearer("Bearer fmcp_abc123 ") mock_lookup.assert_awaited_once_with("fmcp_abc123") @@ -60,13 +60,13 @@ async def test_resolve_bearer_returns_user_id_and_scope(): fake_key = MagicMock() fake_key.user_id = 9 fake_key.scope = "read" - with patch("scribe.mcp.auth.lookup_key", AsyncMock(return_value=fake_key)): + with patch("grimoire.mcp.auth.lookup_key", AsyncMock(return_value=fake_key)): assert await resolve_bearer("Bearer fmcp_x") == (9, "read") @pytest.mark.asyncio async def test_resolve_bearer_none_for_invalid(): - with patch("scribe.mcp.auth.lookup_key", AsyncMock(return_value=None)): + with patch("grimoire.mcp.auth.lookup_key", AsyncMock(return_value=None)): assert await resolve_bearer("Bearer nope") is None assert await resolve_bearer(None) is None @@ -75,7 +75,7 @@ async def test_resolve_bearer_none_for_invalid(): def test_body_calls_write_tool_classifies_correctly(): import json - from scribe.mcp.server import _body_calls_write_tool + from grimoire.mcp.server import _body_calls_write_tool def call(name): return json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", @@ -99,7 +99,7 @@ def test_body_calls_write_tool_classifies_correctly(): def _registered_tool_names() -> set[str]: """What the server actually mounts — not a glob of function names, which would count helpers and miss anything registered another way.""" - from scribe.mcp.server import build_mcp_server + from grimoire.mcp.server import build_mcp_server return {tool.name for tool in build_mcp_server()._tool_manager.list_tools()} @@ -108,18 +108,17 @@ def test_every_registered_tool_is_classified_exactly_once(): """Every tool must be declared a read, a write, or a read-shaped write. `_READ_ONLY_TOOLS` is hand-maintained, and default-deny means a read - omitted from it fails CLOSED — safe, but silent. That is how a read key - ended up able to `get_note` and not `get_snippet` (#2496), and how - `rules_due_for_verification` and `rule_history` sat denied (#3191). + omitted from it fails CLOSED — safe, but silent: a read key able to + `get_note` and not `get_snippet`. - The fix keeps the DECISION explicit and derives the CANDIDATES. Deriving + So the DECISION stays explicit and the CANDIDATES are derived. Deriving the decision would make a security boundary follow a naming convention, so - any future `get_*` grants itself access. This test used to derive - candidates from names too — tools starting `get_`, `list_`, `search`… — and - that narrowing is exactly what let the two #3191 reads through: neither - name looked like a read. So the candidates are now EVERY registered tool. + any future `get_*` grants itself access. Deriving the candidates from names + — tools starting `get_`, `list_`, `search`… — misses reads whose names do + not look like reads (`rules_due_for_verification`, `rule_history`). So the + candidates are EVERY registered tool. """ - from scribe.mcp.server import ( + from grimoire.mcp.server import ( _DELIBERATELY_WRITE_SCOPED, _READ_ONLY_TOOLS, _WRITE_TOOLS, ) @@ -156,8 +155,8 @@ def test_every_registered_tool_is_classified_exactly_once(): def test_the_completeness_check_can_fail(): """A guard that cannot fail is indistinguishable from one that is broken - (rule 167). Drop a real tool from its set and the check must notice.""" - from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS + Drop a real tool from its set and the check must notice.""" + from grimoire.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS registered = _registered_tool_names() assert "rule_history" in _READ_ONLY_TOOLS diff --git a/tests/test_mcp_context.py b/tests/test_mcp_context.py index e614b845..7f08cdc2 100644 --- a/tests/test_mcp_context.py +++ b/tests/test_mcp_context.py @@ -1,7 +1,7 @@ """Tests for MCP per-request user_id context.""" import pytest -from scribe.mcp._context import current_user_id, _user_id_ctx +from grimoire.mcp._context import current_user_id, _user_id_ctx def test_current_user_id_raises_when_unset(): diff --git a/tests/test_mcp_endpoint.py b/tests/test_mcp_endpoint.py index dc99de80..50f5bf0d 100644 --- a/tests/test_mcp_endpoint.py +++ b/tests/test_mcp_endpoint.py @@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.app import create_app +from grimoire.app import create_app async def _send_request( @@ -73,7 +73,7 @@ async def test_mcp_endpoint_unauthenticated_returns_401(): async def test_mcp_endpoint_invalid_token_returns_401(): app = create_app() with patch( - "scribe.mcp.auth.lookup_key", + "grimoire.mcp.auth.lookup_key", AsyncMock(return_value=None), ): status, _ = await _send_request( @@ -107,7 +107,7 @@ async def test_mcp_endpoint_valid_token_passes_auth(): }).encode() async with app.mcp_instance.session_manager.run(): with patch( - "scribe.mcp.auth.lookup_key", + "grimoire.mcp.auth.lookup_key", AsyncMock(return_value=fake_key), ): status, _ = await _send_request( diff --git a/tests/test_mcp_list_family.py b/tests/test_mcp_list_family.py index 352843bc..f90d0bee 100644 --- a/tests/test_mcp_list_family.py +++ b/tests/test_mcp_list_family.py @@ -1,4 +1,4 @@ -"""Cross-family contracts for the `list_*` MCP tools (#2278, shape 4). +"""Cross-family contracts for the `list_*` MCP tools. Per-tool tests cover what each list tool does. Nothing covered what the FAMILY owes its callers, which is where the missing-sibling shape hides: a capability @@ -16,11 +16,10 @@ a real promise rather than a shape coincidence. THE CONTRACT: a `limit` without an `offset` is a truncation with no continuation. The caller is told there are 250 results and handed 50, with no -way to ask for the rest. Both tools that had this were capped over a service -that already accepted an offset — `snippets_svc.list_snippets(offset=0)` was -simply not exposed, and `list_processes` passed a hardcoded `offset=0` into -`query_knowledge`. The capability existed one layer down in both cases; only -the door was missing. +way to ask for the rest. The usual cause is a tool capped over a service that +already accepts an offset — the service's `offset` simply not exposed, or a +hardcoded `offset=0` passed into `query_knowledge`. The capability exists one +layer down; only the door is missing. As in `test_mcp_auth`, the CANDIDATES are derived and the DECISION is explicit. Deriving the exemption too would make the contract follow a naming convention, @@ -29,7 +28,7 @@ so any future `list_*` could opt itself out by accident. import ast import pathlib -TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe" / "mcp" / "tools" +TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "grimoire" / "mcp" / "tools" # `limit` here caps a RANKED top-N, not a page into a corpus, so there is no # "rest" to ask for — the 51st most-used tag is not what the caller wanted and diff --git a/tests/test_mcp_strict_args.py b/tests/test_mcp_strict_args.py index cff86143..7cbfd588 100644 --- a/tests/test_mcp_strict_args.py +++ b/tests/test_mcp_strict_args.py @@ -1,20 +1,19 @@ -"""Unknown tool arguments are rejected, never silently dropped (#2709). +"""Unknown tool arguments are rejected, never silently dropped. The failure this pins: MCPServer validates tool arguments with a pydantic model whose extra-field policy is "ignore", so `create_note(content=...)` — a -plausible near-miss for `body=`, primed by add_task_log's `content` — ran -successfully, stored `body: ""`, and left a record embedding/search cannot -see. The call REPORTED SUCCESS. Two real notes were persisted body-less -before the pattern was noticed. +plausible near-miss for `body=`, primed by add_task_log's `content` — would +run, store `body: ""`, and leave a record embedding/search cannot see, while +REPORTING SUCCESS. -The fix is at the dispatch seam, not per-tool: StrictArgsMCPServer rejects any +The rejection is at the dispatch seam, not per-tool: StrictArgsMCPServer rejects any call carrying arguments the tool does not declare, with a did-you-mean when one is close. An error the caller sees once beats data half-written forever. """ import pytest from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError -from scribe.mcp.server import StrictArgsMCPServer +from grimoire.mcp.server import StrictArgsMCPServer def _echo_server() -> StrictArgsMCPServer: @@ -59,11 +58,11 @@ async def test_unknown_tool_keeps_the_upstream_error(): assert "no_such_tool" in str(exc.value) -# --- a refusal's text reaches the agent (#4794) ------------------------------- +# --- a refusal's text reaches the agent --------------------------------------- # # SDK 2.x passes on only a ToolError's text; anything else is wrapped as # "Error executing tool X" and its message is kept on the server. ValueError is -# how every Scribe tool refuses, so for weeks every refusal arrived bare. +# how every Grimoire tool refuses, so without this every refusal arrives bare. def _raising_server() -> StrictArgsMCPServer: @@ -104,18 +103,18 @@ async def test_a_crash_stays_masked(): async def test_a_real_tools_refusal_reaches_the_caller(): """Against the real server, on a refusal that needs no database: the message judge_menu writes for an empty verdict list.""" - from scribe.mcp.server import build_mcp_server + from grimoire.mcp.server import build_mcp_server with pytest.raises(ToolError) as exc: await build_mcp_server().call_tool("judge_menu", {"log_id": 1, "verdicts": []}) assert "at least one verdict" in str(exc.value) -async def test_the_original_regression_create_note_with_content(): - """Pin #2709 itself against the REAL server: `content=` on create_note +async def test_the_real_server_refuses_content_on_create_note(): + """Against the REAL server: `content=` on create_note must raise before dispatch — naming the bad argument and listing `body` among the accepted ones — instead of creating a body-less note.""" - from scribe.mcp.server import build_mcp_server + from grimoire.mcp.server import build_mcp_server mcp = build_mcp_server() assert isinstance(mcp, StrictArgsMCPServer) # the guard is actually mounted diff --git a/tests/test_mcp_tool_create_records.py b/tests/test_mcp_tool_create_records.py index 90076b64..b4f25367 100644 --- a/tests/test_mcp_tool_create_records.py +++ b/tests/test_mcp_tool_create_records.py @@ -1,4 +1,4 @@ -"""The batch create door, and the guessed-id refusal at every create/update door (#4016). +"""The batch create door, and the guessed-id refusal at every create/update door. What the unit lane can pin: the door refuses a guessed id BEFORE the duplicate gate or any write, a batch is refused whole, and a malformed record fails with @@ -9,8 +9,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.services import record_refs -from scribe.services.record_batch import BatchItem, BatchMilestone, check_batch +from grimoire.services import record_refs +from grimoire.services.record_batch import BatchItem, BatchMilestone, check_batch pytestmark = pytest.mark.usefixtures("_bind_user") @@ -23,14 +23,14 @@ def _max(n: int): @pytest.mark.parametrize(("module", "name", "kwargs"), [ - ("scribe.mcp.tools.tasks", "create_task", {"title": "t", "body": "after #101"}), - ("scribe.mcp.tools.tasks", "update_task", {"task_id": 5, "body": "see #101"}), - ("scribe.mcp.tools.notes", "create_note", {"title": "n", "body": "index: #101"}), - ("scribe.mcp.tools.notes", "update_note", {"note_id": 5, "body": "index: #101"}), - ("scribe.mcp.tools.milestones", "create_milestone", {"project_id": 1, "title": "m", "body": "#101"}), - ("scribe.mcp.tools.milestones", "update_milestone", {"project_id": 1, "milestone_id": 2, "body": "#101"}), - ("scribe.mcp.tools.tasks", "start_planning", {"project_id": 1, "title": "p", "body": "step #101"}), - ("scribe.mcp.tools.tasks", "create_records", {"records": [{"title": "a", "body": "#101"}]}), + ("grimoire.mcp.tools.tasks", "create_task", {"title": "t", "body": "after #101"}), + ("grimoire.mcp.tools.tasks", "update_task", {"task_id": 5, "body": "see #101"}), + ("grimoire.mcp.tools.notes", "create_note", {"title": "n", "body": "index: #101"}), + ("grimoire.mcp.tools.notes", "update_note", {"note_id": 5, "body": "index: #101"}), + ("grimoire.mcp.tools.milestones", "create_milestone", {"project_id": 1, "title": "m", "body": "#101"}), + ("grimoire.mcp.tools.milestones", "update_milestone", {"project_id": 1, "milestone_id": 2, "body": "#101"}), + ("grimoire.mcp.tools.tasks", "start_planning", {"project_id": 1, "title": "p", "body": "step #101"}), + ("grimoire.mcp.tools.tasks", "create_records", {"records": [{"title": "a", "body": "#101"}]}), ]) async def test_every_writing_door_refuses_a_guessed_id_before_writing(module, name, kwargs): import importlib @@ -40,13 +40,13 @@ async def test_every_writing_door_refuses_a_guessed_id_before_writing(module, na # Every service a door could reach is a MagicMock that must stay untouched: # the refusal has to land before the duplicate gate and before the write. with _max(100), \ - patch("scribe.services.dedup.find_duplicate_note", AsyncMock()) as dedup, \ - patch("scribe.services.notes.create_note", AsyncMock()) as create, \ - patch("scribe.services.notes.update_note", AsyncMock()) as update, \ - patch("scribe.services.milestones.create_milestone", AsyncMock()) as ms_create, \ - patch("scribe.services.milestones.update_milestone", AsyncMock()) as ms_update, \ - patch("scribe.services.record_batch.create_batch", AsyncMock()) as batch, \ - patch("scribe.services.planning.start_planning", AsyncMock()) as plan: + patch("grimoire.services.dedup.find_duplicate_note", AsyncMock()) as dedup, \ + patch("grimoire.services.notes.create_note", AsyncMock()) as create, \ + patch("grimoire.services.notes.update_note", AsyncMock()) as update, \ + patch("grimoire.services.milestones.create_milestone", AsyncMock()) as ms_create, \ + patch("grimoire.services.milestones.update_milestone", AsyncMock()) as ms_update, \ + patch("grimoire.services.record_batch.create_batch", AsyncMock()) as batch, \ + patch("grimoire.services.planning.start_planning", AsyncMock()) as plan: with pytest.raises(ValueError, match="#101"): await tool(**kwargs) for mock in (dedup, create, update, ms_create, ms_update, batch, plan): @@ -54,14 +54,14 @@ async def test_every_writing_door_refuses_a_guessed_id_before_writing(module, na async def test_a_real_id_passes_the_door(): - from scribe.mcp.tools.tasks import create_task + from grimoire.mcp.tools.tasks import create_task fake = MagicMock(id=90, project_id=None) fake.to_dict.return_value = {"id": 90} with _max(100), \ - patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.mcp.tools.tasks.notes_svc.create_note", AsyncMock(return_value=fake)), \ - patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()): + patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ + patch("grimoire.mcp.tools.tasks.notes_svc.create_note", AsyncMock(return_value=fake)), \ + patch("grimoire.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()): out = await create_task(title="t", body="follows #42") assert out["id"] == 90 @@ -70,26 +70,26 @@ async def test_a_real_id_passes_the_door(): async def test_unknown_record_fields_are_refused_not_dropped(): - from scribe.mcp.tools.tasks import create_records + from grimoire.mcp.tools.tasks import create_records with pytest.raises(ValueError, match=r"record 2 has unknown field\(s\) \['milestone'\]"): await create_records(records=[{"title": "a"}, {"title": "b", "milestone": 4}]) async def test_a_record_type_must_be_task_or_note(): - from scribe.mcp.tools.tasks import create_records + from grimoire.mcp.tools.tasks import create_records with pytest.raises(ValueError, match="type must be 'task' or 'note'"): await create_records(records=[{"title": "a", "type": "rule"}]) async def test_one_duplicate_blocks_the_whole_batch(): - from scribe.mcp.tools.tasks import create_records + from grimoire.mcp.tools.tasks import create_records dup = MagicMock(id=33, title="existing", similarity=1.0, reason="title") gate = AsyncMock(side_effect=[None, dup]) - with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", gate), \ - patch("scribe.mcp.tools.tasks.batch_svc.create_batch", AsyncMock()) as batch: + with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", gate), \ + patch("grimoire.mcp.tools.tasks.batch_svc.create_batch", AsyncMock()) as batch: out = await create_records(records=[{"title": "fresh"}, {"title": "existing"}], project_id=3) assert out["duplicate"] is True and out["record"] == 2 and out["existing_id"] == 33 assert "Nothing in the batch was created" in out["message"] @@ -97,15 +97,15 @@ async def test_one_duplicate_blocks_the_whole_batch(): async def test_create_records_returns_ids_in_order(): - from scribe.mcp.tools.tasks import create_records + from grimoire.mcp.tools.tasks import create_records notes = [] for nid in (51, 53): n = MagicMock(id=nid) n.to_dict.return_value = {"id": nid} notes.append(n) - with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.mcp.tools.tasks.batch_svc.create_batch", AsyncMock(return_value=(None, notes))) as batch: + with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ + patch("grimoire.mcp.tools.tasks.batch_svc.create_batch", AsyncMock(return_value=(None, notes))) as batch: out = await create_records( records=[{"title": "a"}, {"title": "b", "type": "note", "body": "after {{ref:1}}"}], milestone_id=8, @@ -117,11 +117,11 @@ async def test_create_records_returns_ids_in_order(): async def test_start_planning_hands_its_steps_to_the_service(): - from scribe.mcp.tools.tasks import start_planning + from grimoire.mcp.tools.tasks import start_planning - with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)), \ - patch("scribe.mcp.tools.tasks.planning_svc.start_planning", + with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ + patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)), \ + patch("grimoire.mcp.tools.tasks.planning_svc.start_planning", AsyncMock(return_value={"milestone": {"id": 1}})) as svc: await start_planning(project_id=3, title="Plan", body="see {{ref:1}}", steps=[{"title": "Step 1", "kind": "spike"}]) @@ -155,17 +155,17 @@ def test_every_record_needs_a_title(): def test_a_batch_has_a_ceiling(): - from scribe.services.record_batch import MAX_BATCH + from grimoire.services.record_batch import MAX_BATCH with pytest.raises(ValueError, match="at most"): check_batch([BatchItem(title=f"t{i}") for i in range(MAX_BATCH + 1)]) async def test_start_planning_refuses_placeholders_with_no_steps(): - from scribe.services.planning import start_planning + from grimoire.services.planning import start_planning - with patch("scribe.services.planning.projects_svc.get_project", AsyncMock(return_value=MagicMock())), \ - patch("scribe.services.planning.milestones_svc.create_milestone", AsyncMock()) as create: + with patch("grimoire.services.planning.projects_svc.get_project", AsyncMock(return_value=MagicMock())), \ + patch("grimoire.services.planning.milestones_svc.create_milestone", AsyncMock()) as create: with pytest.raises(ValueError, match="no steps were given"): await start_planning(user_id=7, project_id=3, title="p", body="see {{ref:1}}") create.assert_not_awaited() diff --git a/tests/test_mcp_tool_design_systems.py b/tests/test_mcp_tool_design_systems.py index f4dc92f5..91572c44 100644 --- a/tests/test_mcp_tool_design_systems.py +++ b/tests/test_mcp_tool_design_systems.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch import pytest -from scribe.services.design_systems import DesignSystemCycle +from grimoire.services.design_systems import DesignSystemCycle from tests.helpers import design_token_stub, fake_record @@ -31,29 +31,29 @@ async def test_creating_without_a_parent_passes_none_not_zero(): """0 is the "omitted" sentinel, and it must not reach the service as a system id — there is no system 0, so the create would fail an ACL check for a record that cannot exist.""" - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.create_design_system = AsyncMock(return_value=_fake_design_system()) - from scribe.mcp.tools.design_systems import create_design_system + from grimoire.mcp.tools.design_systems import create_design_system await create_design_system(title="FabledSword") assert svc.create_design_system.await_args.kwargs["parent_id"] is None @pytest.mark.asyncio async def test_creating_with_a_parent_passes_it_through(): - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.create_design_system = AsyncMock(return_value=_fake_design_system()) - from scribe.mcp.tools.design_systems import create_design_system - await create_design_system(title="Scribe", parent_id=4) + from grimoire.mcp.tools.design_systems import create_design_system + await create_design_system(title="Grimoire", parent_id=4) assert svc.create_design_system.await_args.kwargs["parent_id"] == 4 @pytest.mark.asyncio async def test_create_raises_when_the_parent_is_not_writable(): - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.create_design_system = AsyncMock(return_value=None) - from scribe.mcp.tools.design_systems import create_design_system + from grimoire.mcp.tools.design_systems import create_design_system with pytest.raises(ValueError): - await create_design_system(title="Scribe", parent_id=4) + await create_design_system(title="Grimoire", parent_id=4) # --- the three-state parent ------------------------------------------------- @@ -61,9 +61,9 @@ async def test_create_raises_when_the_parent_is_not_writable(): @pytest.mark.asyncio async def test_update_with_parent_id_zero_leaves_the_parent_alone(): """The common case — renaming a system must not silently re-root it.""" - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_design_system = AsyncMock(return_value=_fake_design_system()) - from scribe.mcp.tools.design_systems import update_design_system + from grimoire.mcp.tools.design_systems import update_design_system await update_design_system(design_system_id=1, title="Renamed") fields = svc.update_design_system.await_args.kwargs assert "parent_id" not in fields @@ -75,18 +75,18 @@ async def test_update_with_parent_id_minus_one_clears_it(): """-1 means "make this a family system". It has to arrive at the service as None, which is the value the service reads as "become a root" — where omitting the key means "leave alone".""" - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_design_system = AsyncMock(return_value=_fake_design_system()) - from scribe.mcp.tools.design_systems import update_design_system + from grimoire.mcp.tools.design_systems import update_design_system await update_design_system(design_system_id=1, parent_id=-1) assert svc.update_design_system.await_args.kwargs["parent_id"] is None @pytest.mark.asyncio async def test_update_with_a_positive_parent_id_sets_it(): - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_design_system = AsyncMock(return_value=_fake_design_system()) - from scribe.mcp.tools.design_systems import update_design_system + from grimoire.mcp.tools.design_systems import update_design_system await update_design_system(design_system_id=1, parent_id=4) assert svc.update_design_system.await_args.kwargs["parent_id"] == 4 @@ -95,11 +95,11 @@ async def test_update_with_a_positive_parent_id_sets_it(): async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found(): """The service raises so this layer can keep the two apart. An agent told "not found" would retry the same call; one told what the loop is can fix it.""" - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_design_system = AsyncMock( side_effect=DesignSystemCycle("2 already inherits from 1") ) - from scribe.mcp.tools.design_systems import update_design_system + from grimoire.mcp.tools.design_systems import update_design_system with pytest.raises(ValueError, match="already inherits"): await update_design_system(design_system_id=1, parent_id=2) @@ -110,9 +110,9 @@ async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found(): async def test_update_token_treats_order_index_minus_one_as_unchanged(): """0 is a VALID order_index, so it cannot double as the omitted sentinel — the same reason the systems tools use -1.""" - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_token = AsyncMock(return_value=_fake_token()) - from scribe.mcp.tools.design_systems import update_design_token + from grimoire.mcp.tools.design_systems import update_design_token await update_design_token(token_id=9, purpose="page bg") fields = svc.update_token.await_args.kwargs assert "order_index" not in fields @@ -121,9 +121,9 @@ async def test_update_token_treats_order_index_minus_one_as_unchanged(): @pytest.mark.asyncio async def test_update_token_accepts_order_index_zero(): - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_token = AsyncMock(return_value=_fake_token()) - from scribe.mcp.tools.design_systems import update_design_token + from grimoire.mcp.tools.design_systems import update_design_token await update_design_token(token_id=9, order_index=0) assert svc.update_token.await_args.kwargs["order_index"] == 0 @@ -132,9 +132,9 @@ async def test_update_token_accepts_order_index_zero(): async def test_update_token_can_set_an_empty_value_map(): """`value_by_mode={}` is meaningful — it strips every mode from a token. The guard is `is not None`, not truthiness, or that edit would be unreachable.""" - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_token = AsyncMock(return_value=_fake_token()) - from scribe.mcp.tools.design_systems import update_design_token + from grimoire.mcp.tools.design_systems import update_design_token await update_design_token(token_id=9, value_by_mode={}) assert svc.update_token.await_args.kwargs["value_by_mode"] == {} @@ -143,9 +143,9 @@ async def test_update_token_can_set_an_empty_value_map(): @pytest.mark.asyncio async def test_clearing_a_projects_design_system_passes_none(): - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.set_project_design_system = AsyncMock(return_value=True) - from scribe.mcp.tools.design_systems import set_project_design_system + from grimoire.mcp.tools.design_systems import set_project_design_system result = await set_project_design_system(project_id=2, design_system_id=-1) assert svc.set_project_design_system.await_args.args[2] is None assert result["design_system_id"] is None @@ -156,7 +156,7 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance(): """The payload has to carry the shadowed entries, not just the winner — dropping them at the serialisation boundary would discard the one thing resolution was built to preserve.""" - from scribe.services.design_cascade import resolve_tokens + from grimoire.services.design_cascade import resolve_tokens resolved = resolve_tokens( @@ -164,9 +164,9 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance(): {1: [design_token_stub("--fs-accent", {"base": "#6b2118"})], 2: [design_token_stub("--fs-accent", {"base": "#5b4a8a"})]}, ) - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.resolve_design_system = AsyncMock(return_value=resolved) - from scribe.mcp.tools.design_systems import resolve_design_system + from grimoire.mcp.tools.design_systems import resolve_design_system payload = await resolve_design_system(design_system_id=2) token = payload["tokens"][0] @@ -181,20 +181,20 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance(): @pytest.mark.asyncio async def test_update_token_can_clear_supersedes_with_an_empty_list(): """`[]` means "this token replaces nothing after all" — a real edit. Guarded - on `is not None` so it isn't swallowed as "unchanged", the same trap #2077 - recorded for update_snippet.""" - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + on `is not None` so it isn't swallowed as "unchanged", the same trap + update_snippet guards against.""" + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_token = AsyncMock(return_value=_fake_token()) - from scribe.mcp.tools.design_systems import update_design_token + from grimoire.mcp.tools.design_systems import update_design_token await update_design_token(token_id=9, supersedes=[]) assert svc.update_token.await_args.kwargs["supersedes"] == [] @pytest.mark.asyncio async def test_update_token_leaves_supersedes_alone_when_omitted(): - with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: + with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc: svc.update_token = AsyncMock(return_value=_fake_token()) - from scribe.mcp.tools.design_systems import update_design_token + from grimoire.mcp.tools.design_systems import update_design_token await update_design_token(token_id=9, purpose="text on action surfaces") assert "supersedes" not in svc.update_token.await_args.kwargs @@ -203,18 +203,18 @@ async def test_update_token_leaves_supersedes_alone_when_omitted(): @pytest.mark.asyncio async def test_get_design_system_carries_the_chain_merged_guidance(): - """enter_project carries only a summary and points here (#4045). The + """enter_project carries only a summary and points here. The system's own `guidance` field is only its departures; a session building UI needs the house style too, so the merged form rides alongside.""" - from scribe.mcp.tools.design_systems import get_design_system + from grimoire.mcp.tools.design_systems import get_design_system merged = [{"design_system_id": 1, "title": "House", "guidance": "house style"}, {"design_system_id": 2, "title": "App", "guidance": "departures"}] - with patch("scribe.mcp.tools.design_systems.ds_svc.get_design_system", + with patch("grimoire.mcp.tools.design_systems.ds_svc.get_design_system", AsyncMock(return_value=_fake_design_system())), \ - patch("scribe.mcp.tools.design_systems.ds_svc.list_tokens", + patch("grimoire.mcp.tools.design_systems.ds_svc.list_tokens", AsyncMock(return_value=[])), \ - patch("scribe.mcp.tools.design_systems.ds_svc.design_context", + patch("grimoire.mcp.tools.design_systems.ds_svc.design_context", AsyncMock(return_value={"guidance": merged})): out = await get_design_system(2) diff --git a/tests/test_mcp_tool_milestones.py b/tests/test_mcp_tool_milestones.py index 19a4b070..1c1e831b 100644 --- a/tests/test_mcp_tool_milestones.py +++ b/tests/test_mcp_tool_milestones.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.tools.milestones import ( +from grimoire.mcp.tools.milestones import ( list_milestones, get_milestone, create_milestone, update_milestone, ) from tests.helpers import fake_milestone, fake_task @@ -16,7 +16,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user") def _no_plan_gate(): """The plan gate reads the database; these tests are about what reaches the service. The gate's own tests re-patch it.""" - with patch("scribe.mcp.tools.milestones.dedup_svc.plan_gate", + with patch("grimoire.mcp.tools.milestones.dedup_svc.plan_gate", AsyncMock(return_value=None)): yield @@ -25,7 +25,7 @@ def _no_plan_gate(): async def test_list_milestones_returns_dict_with_progress(): rows = [{"id": 1, "title": "MS1", "status": "active", "total": 2}] with patch( - "scribe.mcp.tools.milestones.milestones_svc.get_project_milestone_summary", + "grimoire.mcp.tools.milestones.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=rows), ): out = await list_milestones(project_id=1) @@ -36,7 +36,7 @@ async def test_list_milestones_returns_dict_with_progress(): async def test_create_milestone_passes_through(): m = fake_milestone(id=5) mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock): out = await create_milestone(project_id=1, title="new", description="d") assert out["id"] == 5 assert mock.call_args.kwargs["project_id"] == 1 @@ -48,7 +48,7 @@ async def test_create_milestone_passes_through(): async def test_create_milestone_empty_description_becomes_none(): m = fake_milestone() mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock): await create_milestone(project_id=1, title="t", description="") assert mock.call_args.kwargs["description"] is None @@ -58,7 +58,7 @@ async def test_create_milestone_passes_body_through(): """The milestone-as-plan body is forwarded to the service.""" m = fake_milestone(id=5) mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock): await create_milestone(project_id=1, title="t", body="## Goal\n\nship") assert mock.call_args.kwargs["body"] == "## Goal\n\nship" @@ -67,7 +67,7 @@ async def test_create_milestone_passes_body_through(): async def test_create_milestone_empty_body_becomes_none(): m = fake_milestone() mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock): await create_milestone(project_id=1, title="t", body="") assert mock.call_args.kwargs["body"] is None @@ -76,9 +76,9 @@ async def test_create_milestone_empty_body_becomes_none(): async def test_create_milestone_returns_the_active_plan_that_already_covers_it(): match = {"duplicate": True, "existing_id": 4} create = AsyncMock() - with patch("scribe.mcp.tools.milestones.dedup_svc.plan_gate", + with patch("grimoire.mcp.tools.milestones.dedup_svc.plan_gate", AsyncMock(return_value=match)) as gate, \ - patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", create): + patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", create): out = await create_milestone(project_id=1, title="t", description="d", body="b") assert out is match create.assert_not_awaited() @@ -92,8 +92,8 @@ async def test_create_milestone_skips_the_gate_when_forced_or_done(): plan competing with an open one.""" gate = AsyncMock(return_value={"duplicate": True}) create = AsyncMock(return_value=fake_milestone(id=6)) - with patch("scribe.mcp.tools.milestones.dedup_svc.plan_gate", gate), \ - patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", create): + with patch("grimoire.mcp.tools.milestones.dedup_svc.plan_gate", gate), \ + patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", create): assert (await create_milestone(project_id=1, title="t", force=True))["id"] == 6 assert (await create_milestone(project_id=1, title="t", status="done"))["id"] == 6 gate.assert_not_awaited() @@ -103,7 +103,7 @@ async def test_create_milestone_skips_the_gate_when_forced_or_done(): async def test_update_milestone_sends_body(): m = fake_milestone() mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock): await update_milestone(project_id=1, milestone_id=5, body="new plan") assert mock.call_args.kwargs == {"body": "new plan"} @@ -114,19 +114,19 @@ async def test_get_milestone_returns_body_steps_and_rules(): step = fake_task(id=9, title="step 1", milestone_id=5, body="a long step body") applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, "project_rules": [{"id": 3, "title": "own"}]} - with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone", + with patch("grimoire.mcp.tools.milestones.milestones_svc.get_milestone", AsyncMock(return_value=m)), \ - patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress", + patch("grimoire.mcp.tools.milestones.milestones_svc.get_milestone_progress", AsyncMock(return_value={"total": 1, "completed": 0, "pct": 0.0})), \ - patch("scribe.mcp.tools.milestones.notes_svc.list_notes", + patch("grimoire.mcp.tools.milestones.notes_svc.list_notes", AsyncMock(return_value=([step], 1))), \ - patch("scribe.mcp.tools.milestones.rulebooks_svc.get_applicable_rules", + patch("grimoire.mcp.tools.milestones.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)): out = await get_milestone(milestone_id=5) assert out["milestone"]["body"] == "## Goal" assert out["milestone"]["total"] == 1 assert [(s["id"], s["title"], s["status"]) for s in out["steps"]] == [(9, "step 1", "todo")] - # A step's body is get_task's job (#4061); its placement is not. + # A step's body is get_task's job; its placement is not. assert "body" not in out["steps"][0] assert out["steps"][0]["milestone_title"] == m.title assert out["applicable_rules"] == [{"id": 1, "title": "r"}] @@ -134,7 +134,7 @@ async def test_get_milestone_returns_body_steps_and_rules(): @pytest.mark.asyncio async def test_get_milestone_raises_when_not_found(): - with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone", + with patch("grimoire.mcp.tools.milestones.milestones_svc.get_milestone", AsyncMock(return_value=None)): with pytest.raises(ValueError, match="milestone 999 not found"): await get_milestone(milestone_id=999) @@ -144,7 +144,7 @@ async def test_get_milestone_raises_when_not_found(): async def test_update_milestone_only_sends_non_default_fields(): m = fake_milestone() mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock): await update_milestone(project_id=1, milestone_id=5, status="done") args, kwargs = mock.call_args assert args == (7, 5) @@ -156,7 +156,7 @@ async def test_update_milestone_order_index_negative_is_omitted(): """order_index=-1 sentinel means leave unchanged.""" m = fake_milestone() mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock): await update_milestone(project_id=1, milestone_id=5, order_index=-1) assert "order_index" not in mock.call_args.kwargs @@ -166,7 +166,7 @@ async def test_update_milestone_order_index_zero_is_explicit(): """order_index=0 is a real value (top of list), not a sentinel.""" m = fake_milestone() mock = AsyncMock(return_value=m) - with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): + with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock): await update_milestone(project_id=1, milestone_id=5, order_index=0) assert mock.call_args.kwargs["order_index"] == 0 @@ -174,7 +174,7 @@ async def test_update_milestone_order_index_zero_is_explicit(): @pytest.mark.asyncio async def test_update_milestone_raises_when_not_found(): with patch( - "scribe.mcp.tools.milestones.milestones_svc.update_milestone", + "grimoire.mcp.tools.milestones.milestones_svc.update_milestone", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="milestone 999 not found"): diff --git a/tests/test_mcp_tool_notes.py b/tests/test_mcp_tool_notes.py index 6637f25a..f350b703 100644 --- a/tests/test_mcp_tool_notes.py +++ b/tests/test_mcp_tool_notes.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.tools.notes import ( +from grimoire.mcp.tools.notes import ( list_notes, get_note, create_note, update_note, delete_note, ) @@ -15,27 +15,27 @@ pytestmark = pytest.mark.usefixtures("_bind_user") @pytest.fixture(autouse=True) def _no_supersession(): - """Every note read/write now asks for its supersession relations (#278). + """Every note read/write asks for its supersession relations. These are unit tests of the TOOL layer and this job has no database — the - same hazard the `_fake_note` comment below records for note 2109. Stubbed + same hazard the `_fake_note` comment below records. Stubbed to "no relations", which is the state of essentially every note; the relation's own behaviour is covered in test_services_supersession.py, and the attachment is covered explicitly below. """ - with patch("scribe.mcp.tools.notes.supersession_svc.get_relations", + with patch("grimoire.mcp.tools.notes.supersession_svc.get_relations", AsyncMock(return_value={"supersedes": [], "superseded_by": []})): yield @pytest.mark.asyncio async def test_create_note_blocked_by_duplicate_gate(): - from scribe.services.dedup import DuplicateMatch + from grimoire.services.dedup import DuplicateMatch dup = DuplicateMatch(id=88, title="Embeddings notes", similarity=0.94, reason="semantic") create_mock = AsyncMock() - with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.notes.dedup_svc.find_duplicate_note", AsyncMock(return_value=dup)), \ - patch("scribe.mcp.tools.notes.notes_svc.create_note", create_mock): + patch("grimoire.mcp.tools.notes.notes_svc.create_note", create_mock): out = await create_note(title="embeddings", body="notes about embeddings") assert out["duplicate"] is True assert out["existing_id"] == 88 @@ -46,8 +46,8 @@ async def test_create_note_blocked_by_duplicate_gate(): @pytest.mark.asyncio async def test_create_note_force_bypasses_duplicate_gate(): find_mock = AsyncMock() - with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note", find_mock), \ - patch("scribe.mcp.tools.notes.notes_svc.create_note", + with patch("grimoire.mcp.tools.notes.dedup_svc.find_duplicate_note", find_mock), \ + patch("grimoire.mcp.tools.notes.notes_svc.create_note", AsyncMock(return_value=fake_note(id=3))): out = await create_note(title="dup", force=True) assert out["id"] == 3 @@ -58,7 +58,7 @@ async def test_create_note_force_bypasses_duplicate_gate(): async def test_list_notes_repackages_tuple_into_dict(): rows = [fake_note(id=1), fake_note(id=2)] with patch( - "scribe.mcp.tools.notes.notes_svc.list_notes", + "grimoire.mcp.tools.notes.notes_svc.list_notes", AsyncMock(return_value=(rows, 2)), ): out = await list_notes() @@ -69,7 +69,7 @@ async def test_list_notes_repackages_tuple_into_dict(): @pytest.mark.asyncio async def test_list_notes_passes_is_task_false(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock): await list_notes() assert mock.call_args.kwargs["is_task"] is False @@ -78,7 +78,7 @@ async def test_list_notes_passes_is_task_false(): async def test_list_notes_tag_filter_maps_to_list(): """The single-tag string param maps to a one-element list at the service layer.""" mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock): await list_notes(tag="ops") assert mock.call_args.kwargs["tags"] == ["ops"] @@ -86,7 +86,7 @@ async def test_list_notes_tag_filter_maps_to_list(): @pytest.mark.asyncio async def test_list_notes_empty_tag_means_no_filter(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock): await list_notes(tag="") assert mock.call_args.kwargs["tags"] is None @@ -94,7 +94,7 @@ async def test_list_notes_empty_tag_means_no_filter(): @pytest.mark.asyncio async def test_list_notes_search_text_maps_to_q(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock): await list_notes(search_text="kafka") assert mock.call_args.kwargs["q"] == "kafka" @@ -102,7 +102,7 @@ async def test_list_notes_search_text_maps_to_q(): @pytest.mark.asyncio async def test_list_notes_limit_clamped(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock): await list_notes(limit=9999) assert mock.call_args.kwargs["limit"] == 100 @@ -111,7 +111,7 @@ async def test_list_notes_limit_clamped(): async def test_get_note_returns_dict(): fake = fake_note(id=5, title="found") with patch( - "scribe.mcp.tools.notes.notes_svc.get_note_for_user", + "grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(return_value=(fake, "owner")), ): out = await get_note(note_id=5) @@ -120,7 +120,7 @@ async def test_get_note_returns_dict(): # Own record: no provenance noise. assert "shared" not in out # No supersession relations: both keys ABSENT, not present-and-empty. A - # field that always says nothing trains readers to skip fields (#2483). + # field that always says nothing trains readers to skip fields. assert "supersedes" not in out assert "superseded_by" not in out @@ -135,9 +135,9 @@ async def test_get_note_warns_in_words_when_a_later_note_overtook_it(): acts on it confidently either way. """ fake = fake_note(id=5, title="June's answer") - with patch("scribe.mcp.tools.notes.notes_svc.get_note_for_user", + with patch("grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(return_value=(fake, "owner"))), \ - patch("scribe.mcp.tools.notes.supersession_svc.get_relations", + patch("grimoire.mcp.tools.notes.supersession_svc.get_relations", AsyncMock(return_value={"supersedes": [], "superseded_by": [9]})): out = await get_note(note_id=5) assert out["superseded_by"] == [9] @@ -153,10 +153,10 @@ async def test_get_note_opens_a_shared_record_and_says_whose_it_is(): handed — and the web UI opens the same note fine.""" theirs = fake_note(id=5, title="Their note", user_id=9) with patch( - "scribe.mcp.tools.notes.notes_svc.get_note_for_user", + "grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(return_value=(theirs, "viewer")), ), patch( - "scribe.services.access.describe_provenance", + "grimoire.services.access.describe_provenance", AsyncMock(return_value={"shared": True, "owner": "alex", "permission": "viewer"}), ): @@ -169,7 +169,7 @@ async def test_get_note_opens_a_shared_record_and_says_whose_it_is(): @pytest.mark.asyncio async def test_get_note_raises_when_not_found(): with patch( - "scribe.mcp.tools.notes.notes_svc.get_note_for_user", + "grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="note 999 not found"): @@ -183,7 +183,7 @@ async def test_get_note_treats_a_trashed_note_as_missing(): trashed = fake_note(id=5) trashed.deleted_at = "2026-07-01T00:00:00Z" with patch( - "scribe.mcp.tools.notes.notes_svc.get_note_for_user", + "grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(return_value=(trashed, "owner")), ): with pytest.raises(ValueError, match="note 5 not found"): @@ -194,7 +194,7 @@ async def test_get_note_treats_a_trashed_note_as_missing(): async def test_create_note_passes_through(): fake = fake_note(id=10, title="new") mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.notes.notes_svc.create_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.create_note", mock): out = await create_note(title="new", body="x", tags=["a"], project_id=5) assert out["id"] == 10 assert mock.call_args.kwargs["title"] == "new" @@ -206,7 +206,7 @@ async def test_create_note_project_zero_becomes_none(): """project_id=0 sentinel must become None at the service layer (orphan note).""" fake = fake_note() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.notes.notes_svc.create_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.create_note", mock): await create_note(title="t", project_id=0) assert mock.call_args.kwargs["project_id"] is None @@ -218,10 +218,10 @@ async def test_update_note_only_sends_non_default_fields(): `clear` is always forwarded and is not a field: it is how this door says "unset these", and an empty one says "unset nothing". Same shape the rules - door took in #3096, and the same test that had to learn about it there.""" + door takes, and the same test.""" fake = fake_note() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock): await update_note(note_id=1, title="new title") args, kwargs = mock.call_args assert args == (7, 1) @@ -232,13 +232,13 @@ async def test_update_note_only_sends_non_default_fields(): @pytest.mark.asyncio async def test_update_note_forwards_a_check_but_not_an_empty_one(): """"" means "leave this alone" at this door — an agent updating a body must - not wipe a check it was never asked about (milestone 317).""" + not wipe a check it was never asked about.""" mock = AsyncMock(return_value=fake_note()) - with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock): await update_note(note_id=1, verify_with="curl the docs") assert mock.call_args.kwargs["verify_with"] == "curl the docs" - with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock): await update_note(note_id=1, title="t") assert "verify_with" not in mock.call_args.kwargs @@ -247,7 +247,7 @@ async def test_update_note_forwards_a_check_but_not_an_empty_one(): async def test_update_note_forwards_clear_so_a_check_can_be_removed(): """The only way to unset a field at a door where "" means "leave alone".""" mock = AsyncMock(return_value=fake_note()) - with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock): await update_note(note_id=1, clear=["verify_with"]) assert mock.call_args.kwargs["clear"] == ["verify_with"] @@ -255,7 +255,7 @@ async def test_update_note_forwards_clear_so_a_check_can_be_removed(): @pytest.mark.asyncio async def test_create_note_forwards_the_check_fields(): mock = AsyncMock(return_value=fake_note()) - with patch("scribe.mcp.tools.notes.notes_svc.create_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.create_note", mock): await create_note(title="t", verify_with="curl", expires_when="AMO changes") assert mock.call_args.kwargs["verify_with"] == "curl" assert mock.call_args.kwargs["expires_when"] == "AMO changes" @@ -266,7 +266,7 @@ async def test_update_note_empty_tags_clears_explicitly(): """tags=[] is an explicit clear, distinct from tags=None (omit).""" fake = fake_note() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock): await update_note(note_id=1, tags=[]) kwargs = dict(mock.call_args.kwargs) kwargs.pop("clear") @@ -277,7 +277,7 @@ async def test_update_note_empty_tags_clears_explicitly(): async def test_update_note_tags_none_means_omit(): fake = fake_note() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock): await update_note(note_id=1, tags=None) assert "tags" not in mock.call_args.kwargs @@ -285,7 +285,7 @@ async def test_update_note_tags_none_means_omit(): @pytest.mark.asyncio async def test_update_note_raises_when_not_found(): with patch( - "scribe.mcp.tools.notes.notes_svc.update_note", + "grimoire.mcp.tools.notes.notes_svc.update_note", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="note 999 not found"): @@ -297,15 +297,15 @@ async def test_delete_note_soft_deletes_and_returns_batch(): doomed = MagicMock() doomed.title = "the note that went" with patch( - "scribe.mcp.tools.notes.trash_svc.delete", + "grimoire.mcp.tools.notes.trash_svc.delete", AsyncMock(return_value="batch-1"), ), patch( - "scribe.mcp.tools.notes.notes_svc.get_note_for_user", + "grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(return_value=(doomed, "owner")), ): result = await delete_note(note_id=7) assert result["deleted_batch_id"] == "batch-1" - # The confirmation NAMES what went (#3273). After the delete the row is + # The confirmation NAMES what went. After the delete the row is # trashed, so this line is the last chance to say which note it was. assert result["title"] == "the note that went" assert "the note that went" in result["message"] @@ -316,10 +316,10 @@ async def test_delete_note_still_deletes_when_the_title_lookup_fails(): """The title is a courtesy on top of the delete, never a precondition for it. A lookup that errors costs the name, not the operation.""" with patch( - "scribe.mcp.tools.notes.trash_svc.delete", + "grimoire.mcp.tools.notes.trash_svc.delete", AsyncMock(return_value="batch-1"), ), patch( - "scribe.mcp.tools.notes.notes_svc.get_note_for_user", + "grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(side_effect=RuntimeError("database down")), ): result = await delete_note(note_id=7) @@ -330,10 +330,10 @@ async def test_delete_note_still_deletes_when_the_title_lookup_fails(): @pytest.mark.asyncio async def test_delete_note_raises_when_not_found(): with patch( - "scribe.mcp.tools.notes.trash_svc.delete", + "grimoire.mcp.tools.notes.trash_svc.delete", AsyncMock(return_value=None), ), patch( - "scribe.mcp.tools.notes.notes_svc.get_note_for_user", + "grimoire.mcp.tools.notes.notes_svc.get_note_for_user", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="note 999 not found"): diff --git a/tests/test_mcp_tool_planning.py b/tests/test_mcp_tool_planning.py index b0bf4670..169263e5 100644 --- a/tests/test_mcp_tool_planning.py +++ b/tests/test_mcp_tool_planning.py @@ -10,7 +10,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user") @pytest.fixture(autouse=True) def _no_plan_gate(): """The plan gate reads the database; the tests that are about it re-patch it.""" - with patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)): + with patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)): yield @@ -18,9 +18,9 @@ def _no_plan_gate(): async def test_start_planning_tool_delegates_to_service(): payload = {"milestone": {"id": 5}, "applicable_rules": [], "project_rules": [], "applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0} - with patch("scribe.mcp.tools.tasks.planning_svc.start_planning", + with patch("grimoire.mcp.tools.tasks.planning_svc.start_planning", AsyncMock(return_value=payload)) as mock: - from scribe.mcp.tools.tasks import start_planning + from grimoire.mcp.tools.tasks import start_planning out = await start_planning(project_id=3, title="Plan it") assert out["milestone"]["id"] == 5 # No body and no steps reach the service as None, so it seeds the template @@ -32,16 +32,15 @@ async def test_start_planning_tool_delegates_to_service(): @pytest.mark.asyncio async def test_start_planning_returns_the_plan_that_already_covers_it(): - """The FabledLibrarian failure (milestone 415): a session that could not see - the existing plan made a second one. Now it is handed the first, and - nothing is created.""" + """A session that cannot see the existing plan makes a second one. It is + handed the first instead, and nothing is created.""" match = {"duplicate": True, "existing_id": 12} svc = AsyncMock() - with patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate", + with patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=match)) as gate, \ - patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock()) as steps, \ - patch("scribe.mcp.tools.tasks.planning_svc.start_planning", svc): - from scribe.mcp.tools.tasks import start_planning + patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock()) as steps, \ + patch("grimoire.mcp.tools.tasks.planning_svc.start_planning", svc): + from grimoire.mcp.tools.tasks import start_planning out = await start_planning(project_id=3, title="Metadata", body="design", steps=[{"title": "Resolve editions", "body": "via providers"}]) assert out is match @@ -56,10 +55,10 @@ async def test_start_planning_returns_the_plan_that_already_covers_it(): @pytest.mark.asyncio async def test_force_creates_the_plan_without_asking_the_gate(): gate = AsyncMock(return_value={"duplicate": True}) - with patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate", gate), \ - patch("scribe.mcp.tools.tasks.planning_svc.start_planning", + with patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate", gate), \ + patch("grimoire.mcp.tools.tasks.planning_svc.start_planning", AsyncMock(return_value={"milestone": {"id": 5}})) as svc: - from scribe.mcp.tools.tasks import start_planning + from grimoire.mcp.tools.tasks import start_planning out = await start_planning(project_id=3, title="Metadata", force=True) assert out["milestone"]["id"] == 5 svc.assert_awaited_once() @@ -70,11 +69,11 @@ async def test_force_creates_the_plan_without_asking_the_gate(): async def test_get_task_augments_plan_with_rules(): applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, "project_rules": [{"id": 3, "title": "own"}]} - with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", + with patch("grimoire.mcp.tools.tasks.notes_svc.get_note_for_user", AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \ - patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", + patch("grimoire.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)): - from scribe.mcp.tools.tasks import get_task + from grimoire.mcp.tools.tasks import get_task out = await get_task(task_id=9) assert out["applicable_rules"] == [{"id": 1, "title": "r"}] assert out["project_rules"] == [{"id": 3, "title": "own"}] @@ -84,11 +83,11 @@ async def test_get_task_augments_plan_with_rules(): @pytest.mark.asyncio async def test_get_task_work_kind_has_no_rules(): - with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", + with patch("grimoire.mcp.tools.tasks.notes_svc.get_note_for_user", AsyncMock(return_value=(fake_task(task_kind="work", id=9, project_id=3), "owner"))), \ - patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", + patch("grimoire.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", AsyncMock()) as mock_rules: - from scribe.mcp.tools.tasks import get_task + from grimoire.mcp.tools.tasks import get_task out = await get_task(task_id=9) assert "applicable_rules" not in out assert not mock_rules.called diff --git a/tests/test_mcp_tool_processes.py b/tests/test_mcp_tool_processes.py index 5a8f421e..20ffb168 100644 --- a/tests/test_mcp_tool_processes.py +++ b/tests/test_mcp_tool_processes.py @@ -10,7 +10,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user") @pytest.mark.asyncio async def test_create_process_requires_title_and_body(): - from scribe.mcp.tools.processes import create_process + from grimoire.mcp.tools.processes import create_process with pytest.raises(ValueError): await create_process(title="", body="something") with pytest.raises(ValueError): @@ -20,11 +20,11 @@ async def test_create_process_requires_title_and_body(): @pytest.mark.asyncio async def test_create_process_sets_note_type(): created = fake_note(title="Drift Audit", note_type="process") - with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.services.notes.create_note", + patch("grimoire.services.notes.create_note", AsyncMock(return_value=created)) as mock_create: - from scribe.mcp.tools.processes import create_process + from grimoire.mcp.tools.processes import create_process out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"]) assert out["note_type"] == "process" # the service was asked to create a process @@ -36,17 +36,17 @@ async def test_create_process_sets_note_type(): async def test_create_process_blocks_a_near_duplicate(): """The gate matters more for processes than for other kinds: each one becomes a skill file that auto-surfaces, so two near-identical procedures don't just - bloat the corpus — they compete to be followed (#2250).""" - from scribe.services.dedup import DuplicateMatch + bloat the corpus — they compete to be followed.""" + from grimoire.services.dedup import DuplicateMatch # The real dataclass, not a MagicMock: a mock answers every attribute, so it # would pass whatever field names this test happened to guess and prove # nothing about the payload the tool actually returns. match = DuplicateMatch(id=42, title="Drift Audit", similarity=0.94, reason="semantic") - with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note", AsyncMock(return_value=match)), \ - patch("scribe.services.notes.create_note", AsyncMock()) as mock_create: - from scribe.mcp.tools.processes import create_process + patch("grimoire.services.notes.create_note", AsyncMock()) as mock_create: + from grimoire.mcp.tools.processes import create_process out = await create_process(title="Drift Audit", body="the prompt") assert out["duplicate"] is True assert out["existing_id"] == 42 @@ -56,11 +56,11 @@ async def test_create_process_blocks_a_near_duplicate(): @pytest.mark.asyncio async def test_create_process_force_bypasses_the_gate(): created = fake_note(title="Drift Audit", note_type="process") - with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note", AsyncMock()) as find_mock, \ - patch("scribe.services.notes.create_note", + patch("grimoire.services.notes.create_note", AsyncMock(return_value=created)): - from scribe.mcp.tools.processes import create_process + from grimoire.mcp.tools.processes import create_process await create_process(title="Drift Audit", body="the prompt", force=True) find_mock.assert_not_awaited() @@ -68,9 +68,9 @@ async def test_create_process_force_bypasses_the_gate(): @pytest.mark.asyncio async def test_get_process_returns_body_and_candidates(): note = fake_note(id=7, title="Drift Audit", note_type="process") - with patch("scribe.services.notes.resolve_process", + with patch("grimoire.services.notes.resolve_process", AsyncMock(return_value=(note, [{"id": 9, "title": "Drift Audit Notes"}]))): - from scribe.mcp.tools.processes import get_process + from grimoire.mcp.tools.processes import get_process out = await get_process("drift") assert out["id"] == 7 assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}] @@ -85,12 +85,12 @@ async def test_get_process_flags_another_users_process(): the returned body', so an unlabelled one would put someone else's procedure in charge of the session.""" note = fake_note(id=7, title="Drift Audit", user_id=9, note_type="process") - with patch("scribe.services.notes.resolve_process", + with patch("grimoire.services.notes.resolve_process", AsyncMock(return_value=(note, []))), \ - patch("scribe.services.access.describe_provenance", + patch("grimoire.services.access.describe_provenance", AsyncMock(return_value={"shared": True, "owner": "alex", "permission": "viewer"})): - from scribe.mcp.tools.processes import get_process + from grimoire.mcp.tools.processes import get_process out = await get_process("deploy") assert out["shared"] is True assert out["owner"] == "alex" @@ -98,9 +98,9 @@ async def test_get_process_flags_another_users_process(): @pytest.mark.asyncio async def test_get_process_not_found_raises(): - with patch("scribe.services.notes.resolve_process", + with patch("grimoire.services.notes.resolve_process", AsyncMock(return_value=(None, []))): - from scribe.mcp.tools.processes import get_process + from grimoire.mcp.tools.processes import get_process with pytest.raises(ValueError): await get_process("missing") @@ -111,9 +111,9 @@ async def test_update_process_rejects_non_process_note(): # returns (note, permission). plain = fake_note(id=3, title="Drift Audit", note_type="note") plain.deleted_at = None - with patch("scribe.services.notes.get_note_for_user", + with patch("grimoire.services.notes.get_note_for_user", AsyncMock(return_value=(plain, "owner"))): - from scribe.mcp.tools.processes import update_process + from grimoire.mcp.tools.processes import update_process with pytest.raises(ValueError): await update_process(process_id=3, title="x") @@ -125,11 +125,11 @@ async def test_update_process_refuses_a_read_only_share_with_the_reason(): open would just send them looking for a missing id.""" theirs = fake_note(id=5, title="Drift Audit", user_id=9, note_type="process") theirs.deleted_at = None - with patch("scribe.services.notes.get_note_for_user", + with patch("grimoire.services.notes.get_note_for_user", AsyncMock(return_value=(theirs, "viewer"))), \ - patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)), \ - patch("scribe.services.notes.update_note", AsyncMock()) as mock_update: - from scribe.mcp.tools.processes import update_process + patch("grimoire.services.access.can_write_note", AsyncMock(return_value=False)), \ + patch("grimoire.services.notes.update_note", AsyncMock()) as mock_update: + from grimoire.mcp.tools.processes import update_process with pytest.raises(ValueError, match="read-only"): await update_process(process_id=5, title="x") mock_update.assert_not_awaited() @@ -139,11 +139,11 @@ async def test_update_process_refuses_a_read_only_share_with_the_reason(): async def test_delete_process_trashes_it_recoverably(): proc = fake_note(id=4, title="Drift Audit", note_type="process") proc.deleted_at = None - with patch("scribe.services.notes.get_note_for_user", + with patch("grimoire.services.notes.get_note_for_user", AsyncMock(return_value=(proc, "owner"))), \ - patch("scribe.mcp.tools.processes.trash_svc.delete", + patch("grimoire.mcp.tools.processes.trash_svc.delete", AsyncMock(return_value="batch-1")) as mock_delete: - from scribe.mcp.tools.processes import delete_process + from grimoire.mcp.tools.processes import delete_process out = await delete_process(process_id=4) assert out["deleted_batch_id"] == "batch-1" # Through the trash, not a hard delete — restorable like every other kind. @@ -157,26 +157,26 @@ async def test_delete_process_refuses_a_plain_note(): mistyped argument.""" plain = fake_note(id=3, title="Drift Audit", note_type="note") plain.deleted_at = None - with patch("scribe.services.notes.get_note_for_user", + with patch("grimoire.services.notes.get_note_for_user", AsyncMock(return_value=(plain, "owner"))), \ - patch("scribe.mcp.tools.processes.trash_svc.delete", AsyncMock()) as mock_delete: - from scribe.mcp.tools.processes import delete_process + patch("grimoire.mcp.tools.processes.trash_svc.delete", AsyncMock()) as mock_delete: + from grimoire.mcp.tools.processes import delete_process with pytest.raises(ValueError): await delete_process(process_id=3) mock_delete.assert_not_awaited() -# ── the moments a procedure is for (milestone 458 step 5) ──────────────── +# ── the moments a procedure is for ─────────────────────────────────────── @pytest.mark.asyncio async def test_create_process_stores_the_moments_it_is_for(): created = fake_note(title="Release", note_type="process", data={"moments": ["work.deliver"]}) - with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.services.notes.create_note", + patch("grimoire.services.notes.create_note", AsyncMock(return_value=created)) as mock_create: - from scribe.mcp.tools.processes import create_process + from grimoire.mcp.tools.processes import create_process out = await create_process(title="Release", body="steps", moments=["Work.Deliver", "work.deliver"]) assert mock_create.await_args.kwargs["data"] == {"moments": ["work.deliver"]} @@ -187,10 +187,10 @@ async def test_create_process_stores_the_moments_it_is_for(): async def test_create_process_refuses_an_unknown_moment_before_writing(): """A typo'd moment would be stored and never fire — a procedure that looks attached and is not.""" - with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.services.notes.create_note", AsyncMock()) as mock_create: - from scribe.mcp.tools.processes import create_process + patch("grimoire.services.notes.create_note", AsyncMock()) as mock_create: + from grimoire.mcp.tools.processes import create_process with pytest.raises(ValueError, match="unknown moment"): await create_process(title="Release", body="steps", moments=["work.shipped"]) mock_create.assert_not_awaited() @@ -199,11 +199,11 @@ async def test_create_process_refuses_an_unknown_moment_before_writing(): @pytest.mark.asyncio async def test_create_process_without_moments_stores_no_data(): created = fake_note(title="Release", note_type="process") - with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.services.notes.create_note", + patch("grimoire.services.notes.create_note", AsyncMock(return_value=created)) as mock_create: - from scribe.mcp.tools.processes import create_process + from grimoire.mcp.tools.processes import create_process out = await create_process(title="Release", body="steps") assert mock_create.await_args.kwargs["data"] is None assert out["moments"] == [] @@ -211,12 +211,12 @@ async def test_create_process_without_moments_stores_no_data(): async def _update(moments, data): proc = fake_note(id=5, title="Release", note_type="process", data=data) - with patch("scribe.services.notes.get_note_for_user", + with patch("grimoire.services.notes.get_note_for_user", AsyncMock(return_value=(proc, "owner"))), \ - patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \ - patch("scribe.services.notes.update_note", + patch("grimoire.services.access.can_write_note", AsyncMock(return_value=True)), \ + patch("grimoire.services.notes.update_note", AsyncMock(return_value=proc)) as mock_update: - from scribe.mcp.tools.processes import update_process + from grimoire.mcp.tools.processes import update_process await update_process(process_id=5, moments=moments) return mock_update.await_args.kwargs @@ -242,8 +242,8 @@ async def test_update_process_without_moments_leaves_data_alone(): async def test_get_process_says_which_moments_it_is_for(): note = fake_note(id=7, title="Release", note_type="process", data={"moments": ["work.deliver"]}) - with patch("scribe.services.notes.resolve_process", AsyncMock(return_value=(note, []))): - from scribe.mcp.tools.processes import get_process + with patch("grimoire.services.notes.resolve_process", AsyncMock(return_value=(note, []))): + from grimoire.mcp.tools.processes import get_process out = await get_process("release") assert out["moments"] == ["work.deliver"] @@ -253,7 +253,7 @@ def test_register_attaches_every_tool_in_the_module(): registered is invisible to an agent, and nothing else would notice.""" import inspect - from scribe.mcp.tools import processes + from grimoire.mcp.tools import processes mcp = FakeMCP() processes.register(mcp) diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index c3bd8634..5e31703d 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.tools.projects import ( +from grimoire.mcp.tools.projects import ( list_projects, get_project, create_project, update_project, enter_project, ) @@ -17,11 +17,11 @@ pytestmark = pytest.mark.usefixtures("_bind_user") @pytest.fixture(autouse=True) def _no_systems(): """enter_project now surfaces the project's Systems as the tagging - vocabulary (#2546). These are tool-layer unit tests with no database, so + vocabulary. These are tool-layer unit tests with no database, so the lookup is stubbed to the common case — a project with none. The populated shape is asserted in its own test below. """ - with patch("scribe.mcp.tools.projects.systems_svc.list_systems", + with patch("grimoire.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])): yield @@ -33,49 +33,49 @@ def _no_platforms(): case — a project with none yet. The populated shape is asserted in its own test. """ - with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms", + with patch("grimoire.mcp.tools.projects.platforms_svc.project_platforms", AsyncMock(return_value=[])): yield @pytest.fixture(autouse=True) def _no_coverage(): - """enter_project also reads the pattern-coverage cache (#2692) — same + """enter_project also reads the pattern-coverage cache — same deal: no database here, stub the common case (nothing computed). The populated line is asserted in tests/test_pattern_coverage.py. """ - with patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage", + with patch("grimoire.mcp.tools.projects.coverage_svc.cached_coverage", AsyncMock(return_value=None)): yield @pytest.fixture(autouse=True) def _no_family(): - """enter_project reads the family-canon readout (milestone 463 step 6) — + """enter_project reads the family-canon readout — no database here, so the common case is stubbed: nothing to answer. The populated key is asserted in its own test below.""" - with patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout", + with patch("grimoire.mcp.tools.projects.family_adoption_svc.family_readout", AsyncMock(return_value=None)) as mock: yield mock @pytest.fixture(autouse=True) def _no_background_seed(): - """enter_project now fire-and-forgets a coverage self-seed (#2802). These + """enter_project fire-and-forgets a coverage self-seed. These are no-database unit tests, so the spawn is stubbed out; the firing shape has its own test below.""" - with patch("scribe.mcp.tools.projects.spawn") as mock: + with patch("grimoire.mcp.tools.projects.spawn") as mock: yield mock @pytest.fixture(autouse=True) def _no_bootstrap(): """With _no_systems stubbing an empty vocabulary, every test here reaches - the zero-Systems branch, whose bootstrap ask (#2683) counts the project's + the zero-Systems branch, whose bootstrap ask counts the project's records — a database read. Stub the common case (young project, no ask); the firing shape has its own test below. """ - with patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", + with patch("grimoire.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)) as mock: yield mock @@ -84,7 +84,7 @@ def _no_bootstrap(): async def test_list_projects_wraps_in_dict(): rows = [fake_project(id=1), fake_project(id=2)] with patch( - "scribe.mcp.tools.projects.projects_svc.list_projects", + "grimoire.mcp.tools.projects.projects_svc.list_projects", AsyncMock(return_value=rows), ): out = await list_projects() @@ -99,13 +99,13 @@ async def test_get_project_enriches_with_milestone_summary(): "rules": [], "truncated": False, } with patch( - "scribe.mcp.tools.projects.projects_svc.get_project", + "grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( - "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + "grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestone_summary), ), patch( - "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + "grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable_payload), ): out = await get_project(project_id=5) @@ -117,7 +117,7 @@ async def test_get_project_enriches_with_milestone_summary(): async def test_get_project_includes_applicable_rules_and_project_rules(): """The augmented get_project response includes applicable_rules and project_rules pulled from services/rulebooks.get_applicable_rules, and - nothing about subscriptions (milestone 414). + nothing about subscriptions. """ p = fake_project(id=3, title="Fabled Assistant") milestone_summary = [] @@ -131,13 +131,13 @@ async def test_get_project_includes_applicable_rules_and_project_rules(): "truncated": False, } with patch( - "scribe.mcp.tools.projects.projects_svc.get_project", + "grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( - "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + "grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestone_summary), ), patch( - "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + "grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable_payload), ): out = await get_project(project_id=3) @@ -151,7 +151,7 @@ async def test_get_project_includes_applicable_rules_and_project_rules(): @pytest.mark.asyncio async def test_get_project_raises_when_not_found(): with patch( - "scribe.mcp.tools.projects.projects_svc.get_project", + "grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="project 999 not found"): @@ -162,7 +162,7 @@ async def test_get_project_raises_when_not_found(): async def test_create_project_passes_color_empty_as_none(): p = fake_project() mock = AsyncMock(return_value=p) - with patch("scribe.mcp.tools.projects.projects_svc.create_project", mock): + with patch("grimoire.mcp.tools.projects.projects_svc.create_project", mock): await create_project(title="P", color="") assert mock.call_args.kwargs["color"] is None @@ -171,7 +171,7 @@ async def test_create_project_passes_color_empty_as_none(): async def test_update_project_only_sends_non_default_fields(): p = fake_project() mock = AsyncMock(return_value=p) - with patch("scribe.mcp.tools.projects.projects_svc.update_project", mock): + with patch("grimoire.mcp.tools.projects.projects_svc.update_project", mock): await update_project(project_id=1, status="archived") args, kwargs = mock.call_args assert args == (7, 1) @@ -181,7 +181,7 @@ async def test_update_project_only_sends_non_default_fields(): @pytest.mark.asyncio async def test_update_project_raises_when_not_found(): with patch( - "scribe.mcp.tools.projects.projects_svc.update_project", + "grimoire.mcp.tools.projects.projects_svc.update_project", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="project 999 not found"): @@ -191,7 +191,7 @@ async def test_update_project_raises_when_not_found(): @pytest.mark.asyncio async def test_enter_project_composes_full_context(): """enter_project pulls project + rule titles + milestone summary + open - tasks in one composed call, each in its brief handshake form (#4045).""" + tasks in one composed call, each in its brief handshake form.""" p = fake_project(id=5, title="P") applicable_payload = { "rules": [{"id": 1, "title": "r1", "statement": "s", @@ -209,16 +209,16 @@ async def test_enter_project_composes_full_context(): note1.updated_at = None # avoids datetime mocking with patch( - "scribe.mcp.tools.projects.projects_svc.get_project", + "grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( - "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + "grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable_payload), ), patch( - "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + "grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestone_summary), ), patch( - "scribe.mcp.tools.projects.notes_svc.list_notes", + "grimoire.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([task1], 1), ([note1], 1)]), ): out = await enter_project(project_id=5) @@ -226,7 +226,7 @@ async def test_enter_project_composes_full_context(): assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""} assert out["milestone_summary"] == milestone_summary # Rules arrive in full by retrieval; the handshake lists the project's own - # by id and title (#4045), and nothing about subscriptions (milestone 414). + # by id and title, and nothing about subscriptions. assert out["project_rules"] == [{"id": 99, "title": "pr1"}] for gone in ("applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks", "suppressed_rules", "suppressed_topics", "recent_notes"): @@ -244,16 +244,14 @@ async def test_enter_project_composes_full_context(): # create-the-System instruction acts on. assert out["systems"] == [] # Young project, no bootstrap ask -> the key is ABSENT, not null: it exists - # to be acted on, and a key that usually says null gets skipped (#2483). + # to be acted on, and a key that usually says null gets skipped. assert "systems_bootstrap" not in out @pytest.mark.asyncio async def test_enter_project_surfaces_the_systems_vocabulary(): """The tagging instruction is only executable if the vocabulary is in - front of the agent when it writes. It never was, and tagging stopped three - days after the feature landed — one System, nothing tagged since July 28 - (#2546's audit). Trimmed to id/name/first-line: it rides on every session + front of the agent when it writes. Trimmed to id/name/first-line: it rides on every session start, and the full charter is get_system's job.""" p = fake_project(id=5) sys1 = MagicMock() @@ -262,19 +260,19 @@ async def test_enter_project_surfaces_the_systems_vocabulary(): sys1.description = "Embeddings, ranking, auto-inject.\nLong detail below." with patch( - "scribe.mcp.tools.projects.projects_svc.get_project", + "grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( - "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + "grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value={"rules": [], "truncated": False}), ), patch( - "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + "grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[]), ), patch( - "scribe.mcp.tools.projects.notes_svc.list_notes", + "grimoire.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)]), ), patch( - "scribe.mcp.tools.projects.systems_svc.list_systems", + "grimoire.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[sys1]), ): out = await enter_project(project_id=5) @@ -285,20 +283,20 @@ async def test_enter_project_surfaces_the_systems_vocabulary(): def _enter_project_stubs(p): """The four patches every enter_project test repeats, as one context list.""" return [ - patch("scribe.mcp.tools.projects.projects_svc.get_project", + patch("grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p)), - patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + patch("grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value={"rules": [], "truncated": False})), - patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + patch("grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), - patch("scribe.mcp.tools.projects.notes_svc.list_notes", + patch("grimoire.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), ] @pytest.mark.asyncio async def test_enter_project_carries_the_bootstrap_ask_when_it_fires(): - """The arrival-moment half of #2683: a mature zero-Systems project greets + """The arrival-moment half of the bootstrap ask: a mature zero-Systems project greets the session with the concrete bootstrap ask, before it is deep in a task — the moment minting a starter vocabulary is cheapest.""" import contextlib @@ -308,7 +306,7 @@ async def test_enter_project_carries_the_bootstrap_ask_when_it_fires(): for cm in _enter_project_stubs(fake_project(id=5)): stack.enter_context(cm) stack.enter_context(patch( - "scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", + "grimoire.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=ask), )) out = await enter_project(project_id=5) @@ -329,7 +327,7 @@ async def test_enter_project_never_asks_bootstrap_once_a_vocabulary_exists( for cm in _enter_project_stubs(fake_project(id=5)): stack.enter_context(cm) stack.enter_context(patch( - "scribe.mcp.tools.projects.systems_svc.list_systems", + "grimoire.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[sys1]), )) out = await enter_project(project_id=5) @@ -341,7 +339,7 @@ async def test_enter_project_never_asks_bootstrap_once_a_vocabulary_exists( async def test_enter_project_fires_the_coverage_seed_on_the_owner( _no_background_seed, ): - """The arrival self-seed (#2802): entering spawns refresh_if_stale on the + """The arrival self-seed: entering spawns refresh_if_stale on the project OWNER's id with the cache read the enter already did — fire and forget, so a missing ledger seeds itself without the UI button, and the enter stays fast.""" @@ -353,7 +351,7 @@ async def test_enter_project_fires_the_coverage_seed_on_the_owner( for cm in _enter_project_stubs(project): stack.enter_context(cm) seed = stack.enter_context(patch( - "scribe.mcp.tools.projects.coverage_svc.refresh_if_stale", + "grimoire.mcp.tools.projects.coverage_svc.refresh_if_stale", MagicMock(return_value=object()), )) await enter_project(project_id=5) @@ -365,35 +363,35 @@ async def test_enter_project_fires_the_coverage_seed_on_the_owner( @pytest.mark.asyncio async def test_enter_project_hands_back_the_design_system_when_the_project_has_one(): """The handshake is where an agent learns what binds it, and a design - system binds the same way a rule does. Before this it was reachable only by - an agent that already knew to call resolve_design_system — so the standards - were present in the store and absent from the work.""" + system binds the same way a rule does. Reachable only by an agent that + already knows to call resolve_design_system, the standards would be present + in the store and absent from the work.""" p = fake_project(id=5, design_system_id=9) design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}], "token_count": 95, "token_groups": ["surface"], "inherits_from": ["House"], "description": ""} with patch( - "scribe.mcp.tools.projects.projects_svc.get_project", + "grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( - "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + "grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value={"rules": [], "truncated": False}), ), patch( - "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + "grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[]), ), patch( - "scribe.mcp.tools.projects.notes_svc.list_notes", + "grimoire.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)]), ), patch( - "scribe.mcp.tools.projects.design_systems_svc.design_context", + "grimoire.mcp.tools.projects.design_systems_svc.design_context", AsyncMock(return_value=design), ) as ctx: out = await enter_project(project_id=5) assert out["design_system"]["token_count"] == 95 assert out["design_system"]["inherits_from"] == ["House"] - # Summary only: the guidance is one call away (#4045). + # Summary only: the guidance is one call away. assert "guidance" not in out["design_system"] assert out["design_system"]["guidance_call"] == "get_design_system(9) → resolved_guidance" assert ctx.await_args.args == (7, 9) # caller's id, the project's system @@ -402,7 +400,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o @pytest.mark.asyncio async def test_enter_project_raises_when_project_not_found(): with patch( - "scribe.mcp.tools.projects.projects_svc.get_project", + "grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="project 999 not found"): @@ -411,21 +409,21 @@ async def test_enter_project_raises_when_project_not_found(): def test_enter_project_registered_in_register(): """register(mcp) registers enter_project alongside the existing tools.""" - from scribe.mcp.tools.projects import register + from grimoire.mcp.tools.projects import register mcp = FakeMCP() register(mcp) assert "enter_project" in mcp.names -# --- milestone 297: the inception doors --------------------------------------- +# --- the inception doors ----------------------------------------------------- @pytest.mark.asyncio async def test_create_project_without_inception_args_stays_undecided(): p = fake_project(id=5, title="P", inception=None) - with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ - patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide: + with patch("grimoire.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ + patch("grimoire.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide: out = await create_project(title="P") decide.assert_not_awaited() assert "inception_hint" in out and "inception_effects" not in out @@ -435,8 +433,8 @@ async def test_create_project_without_inception_args_stays_undecided(): async def test_create_project_with_inception_args_decides_via_mcp(): p = fake_project(id=5, title="P", inception=None) decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}} - with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ - patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: + with patch("grimoire.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ + patch("grimoire.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: out = await create_project(title="P", design_system_id=-1, seed_systems=True) kw = decide.await_args.kwargs assert decide.await_args.args[1] == 5 and kw["via"] == "mcp" @@ -446,9 +444,9 @@ async def test_create_project_with_inception_args_decides_via_mcp(): @pytest.mark.asyncio async def test_decide_project_inception_tool_records_an_inherit_all_decision_when_given_nothing(): - from scribe.mcp.tools.projects import decide_project_inception + from grimoire.mcp.tools.projects import decide_project_inception decided = {"inception": {"via": "mcp"}, "effects": {}} - with patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: + with patch("grimoire.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: out = await decide_project_inception(project_id=5) assert decide.await_args.kwargs["choices"] == {} assert out["project_id"] == 5 and out["inception"]["via"] == "mcp" @@ -460,13 +458,13 @@ async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"} async def run(project): - with patch("scribe.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \ - patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \ - patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \ - patch("scribe.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \ - patch("scribe.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \ - patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \ - patch("scribe.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked: + with patch("grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \ + patch("grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \ + patch("grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \ + patch("grimoire.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \ + patch("grimoire.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \ + patch("grimoire.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \ + patch("grimoire.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked: return await enter_project(project_id=5), asked # Own + undecided → the ask rides along. @@ -481,8 +479,8 @@ async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own def test_inception_routes_and_tool_are_registered(): - from scribe.app import create_app - from scribe.mcp.server import build_mcp_server + from grimoire.app import create_app + from grimoire.mcp.server import build_mcp_server rules = {r.rule for r in create_app().url_map.iter_rules()} assert "/api/projects//inception" in rules assert "/api/projects//inception/defaults" in rules @@ -491,7 +489,7 @@ def test_inception_routes_and_tool_are_registered(): tool = mcp._tool_manager.get_tool("create_project") for name in ("design_system_id", "seed_systems"): assert name in tool.parameters.get("properties", {}), name - # Rules left inception with subscriptions (milestone 414). + # Inception carries no rulebook subscriptions. assert "subscribe_rulebooks" not in tool.parameters.get("properties", {}) @@ -500,7 +498,7 @@ def test_inception_routes_and_tool_are_registered(): async def test_enter_project_carries_the_family_line_only_when_it_asks_something(_no_family): """The `family` key is attached when the project has family canon to answer, and absent otherwise — a key that usually says null trains - readers to skip it (#2483).""" + readers to skip it.""" line = {"line": "family canon on this project's platforms: 2 unassessed", "unassessed": 2, "owed": 0, "needs_recheck": 0, "calls": {"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")'}} diff --git a/tests/test_mcp_tool_report_back_cue.py b/tests/test_mcp_tool_report_back_cue.py index feee6511..9b29cdac 100644 --- a/tests/test_mcp_tool_report_back_cue.py +++ b/tests/test_mcp_tool_report_back_cue.py @@ -1,11 +1,10 @@ """update_task closes a task with a one-line reminder of what the report needs. -The in-band half of milestone 409 step 3: the skill and static context only +This is the in-band half of reporting back: the skill and static context only exist in the Claude Code plugin, and a tool response reaches every MCP client at the moment a piece of work closes. Pinned on the response, not the wording. -The operator's own completion-report preferences used to ride here too, from -a fixed-question search (409 step 4). Milestone 500 retired that: they are +The operator's own completion-report preferences do not ride here: they are mounted on finishing work and arrive in `moment_rules` beside the delivered reply shape, so this response carries no key of its own for them. """ @@ -17,14 +16,14 @@ pytestmark = pytest.mark.usefixtures("_bind_user") async def _update(owed=None, **kwargs): - from scribe.mcp.tools.tasks import update_task + from grimoire.mcp.tools.tasks import update_task note = MagicMock(id=5, user_id=7, project_id=3, started_at="2026-10-06T09:00:00+00:00") note.to_dict.return_value = {"id": 5} - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \ - patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \ - patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \ - patch("scribe.mcp.tools.tasks.family_adoption_svc.owed_since", + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \ + patch("grimoire.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \ + patch("grimoire.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \ + patch("grimoire.mcp.tools.tasks.family_adoption_svc.owed_since", AsyncMock(return_value=list(owed or []))): return await update_task(task_id=5, **kwargs) @@ -43,7 +42,7 @@ async def test_other_updates_do_not(kwargs): async def test_closing_carries_the_plain_cue_and_no_preference_key_of_its_own(): """The retired key does not come back beside the moment delivery.""" - from scribe.mcp.tools.tasks import REPORT_BACK_CUE + from grimoire.mcp.tools.tasks import REPORT_BACK_CUE out = await _update(status="done") assert "reply_preferences" not in out @@ -55,9 +54,9 @@ _OWED = {"idea_id": 9, "idea_title": "Signed APK lane", "project_id": 4, async def test_closing_names_the_owed_adoptions_filed_while_the_task_was_open(): - """The finishing moment of milestone 463 step 6: owed family work filed + """The finishing moment of family canon: owed family work filed into a project during this task comes back for the report to name.""" - from scribe.services.family_adoption import OWED_CUE + from grimoire.services.family_adoption import OWED_CUE out = await _update(owed=[_OWED], status="done") assert out["family_owed"] == [_OWED] diff --git a/tests/test_mcp_tool_rulebooks.py b/tests/test_mcp_tool_rulebooks.py index 8110cc36..63f54afc 100644 --- a/tests/test_mcp_tool_rulebooks.py +++ b/tests/test_mcp_tool_rulebooks.py @@ -13,10 +13,10 @@ pytestmark = pytest.mark.usefixtures("_bind_user") async def test_list_rulebooks_wraps_in_dict(): rows = [fake_rulebook(id=1, title="t"), fake_rulebook(id=2, title="t")] with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks", AsyncMock(return_value=rows), ): - from scribe.mcp.tools.rulebooks import list_rulebooks + from grimoire.mcp.tools.rulebooks import list_rulebooks out = await list_rulebooks() assert len(out["rulebooks"]) == 2 @@ -26,13 +26,13 @@ async def test_get_rulebook_includes_topics(): rb = fake_rulebook(id=1, title="t") topics = [fake_topic(id=10, title="git"), fake_topic(id=11, title="git")] with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rulebook", AsyncMock(return_value=rb), ), patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.list_topics", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.list_topics", AsyncMock(return_value=topics), ): - from scribe.mcp.tools.rulebooks import get_rulebook + from grimoire.mcp.tools.rulebooks import get_rulebook out = await get_rulebook(rulebook_id=1) assert out["id"] == 1 assert len(out["topics"]) == 2 @@ -41,16 +41,15 @@ async def test_get_rulebook_includes_topics(): @pytest.mark.asyncio async def test_get_rulebook_raises_when_not_found(): with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rulebook", AsyncMock(return_value=None), ): - from scribe.mcp.tools.rulebooks import get_rulebook + from grimoire.mcp.tools.rulebooks import get_rulebook with pytest.raises(ValueError, match="rulebook 999 not found"): await get_rulebook(rulebook_id=999) -# _plain_detail moved to tests/helpers on its second copy (#2825's own -# reason for existing): two stubs for one seam drift apart quietly, and a +# _plain_detail lives in tests/helpers: two stubs for one seam drift apart quietly, and a # test stubbing it slightly differently asserts something slightly different # than it appears to. @@ -59,8 +58,8 @@ async def test_get_rulebook_raises_when_not_found(): async def test_create_rule_passes_required_fields(): rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock = AsyncMock(return_value=rule) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail(): - from scribe.mcp.tools.rulebooks import create_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail(): + from grimoire.mcp.tools.rulebooks import create_rule await create_rule( topic_id=10, title="dev is home", statement="Work directly on dev", when_to_apply="when the moment this fixture stands in for arises", @@ -73,13 +72,13 @@ async def test_create_rule_passes_required_fields(): @pytest.mark.asyncio async def test_create_rule_blocked_by_duplicate_gate(): - from scribe.services.dedup import DuplicateMatch + from grimoire.services.dedup import DuplicateMatch dup = DuplicateMatch(id=47, title="dev is home", similarity=1.0, reason="title") create_mock = AsyncMock() - with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", + with patch("grimoire.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", AsyncMock(return_value=dup)), \ - patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock): - from scribe.mcp.tools.rulebooks import create_rule + patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock): + from grimoire.mcp.tools.rulebooks import create_rule out = await create_rule(topic_id=10, title="dev is home", statement="x", when_to_apply="when the moment this fixture stands in for arises") assert out["duplicate"] is True assert out["existing_id"] == 47 @@ -90,11 +89,11 @@ async def test_create_rule_blocked_by_duplicate_gate(): @pytest.mark.asyncio async def test_create_rule_force_bypasses_duplicate_gate(): find_mock = AsyncMock() - with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \ - patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", + with patch("grimoire.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \ + patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_rule", AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))), \ _plain_detail(): - from scribe.mcp.tools.rulebooks import create_rule + from grimoire.mcp.tools.rulebooks import create_rule out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True, when_to_apply="when the moment this fixture stands in for arises") assert out["id"] == 5 find_mock.assert_not_called() @@ -104,8 +103,8 @@ async def test_create_rule_force_bypasses_duplicate_gate(): async def test_update_rule_only_sends_non_default_fields(): rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock = AsyncMock(return_value=rule) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail(): - from scribe.mcp.tools.rulebooks import update_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail(): + from grimoire.mcp.tools.rulebooks import update_rule await update_rule(rule_id=1, statement="new statement") args, kwargs = mock.call_args assert args == (1, 7) @@ -125,8 +124,8 @@ async def test_update_rule_forwards_the_fields_named_for_clearing(): """ rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock = AsyncMock(return_value=rule) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail(): - from scribe.mcp.tools.rulebooks import update_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail(): + from grimoire.mcp.tools.rulebooks import update_rule await update_rule(rule_id=1, clear_fields=["verify_with"]) _args, kwargs = mock.call_args assert kwargs == {"clear": ["verify_with"]} @@ -136,8 +135,8 @@ async def test_update_rule_forwards_the_fields_named_for_clearing(): async def test_update_rule_sends_the_check_fields_when_given(): rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock = AsyncMock(return_value=rule) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail(): - from scribe.mcp.tools.rulebooks import update_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail(): + from grimoire.mcp.tools.rulebooks import update_rule await update_rule( rule_id=1, verify_with="cat CI-runner/renovate/config.js", @@ -156,13 +155,13 @@ async def test_delete_rule_without_confirmed_returns_warning(): """delete_rule with confirmed=False returns a preview, not an action.""" rule = fake_rule(id=100, title="r", statement="s", topic_id=10) with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=rule), ), patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.delete_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.delete_rule", AsyncMock(), ) as mock_delete: - from scribe.mcp.tools.rulebooks import delete_rule + from grimoire.mcp.tools.rulebooks import delete_rule out = await delete_rule(rule_id=1, confirmed=False) assert out.get("confirmed_required") is True assert "confirmed=True" in out.get("warning", "") @@ -174,13 +173,13 @@ async def test_delete_rule_with_confirmed_soft_deletes(): rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock_delete = AsyncMock(return_value="batch-1") with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=rule), ), patch( - "scribe.mcp.tools.rulebooks.trash_svc.delete", + "grimoire.mcp.tools.rulebooks.trash_svc.delete", mock_delete, ): - from scribe.mcp.tools.rulebooks import delete_rule + from grimoire.mcp.tools.rulebooks import delete_rule out = await delete_rule(rule_id=1, confirmed=True) assert out["deleted"] == 1 assert out["deleted_batch_id"] == "batch-1" @@ -195,20 +194,11 @@ def test_register_attaches_every_tool(): said "sixteen" for ten tools' worth of growth — the number lives in the assertion, not the title, so it cannot drift again. """ - from scribe.mcp.tools.rulebooks import register + from grimoire.mcp.tools.rulebooks import register mcp = FakeMCP() register(mcp) - # 26 through milestone 307, +2 for the staleness sweep (milestone 312), - # +1 for a rule's edit history (milestone 323), +2 for preferences - # (milestone 399). - # 28 since milestone 394 took list_always_on_rules and the two - # always-on exclusion tools with the tier they served. - # 22 since milestone 414 retired subscriptions and suppressions: the two - # subscribe tools and the four suppress/unsuppress tools. 23 with move_rule - # (milestone 414 step 3), the way a rule changes home. 24 with - # rule_outcome (milestone 419 step 1), the way a rule says what it - # actually changed. + # Pinned as a count so a tool added or dropped is a deliberate edit here. assert len(mcp.names) == 24 # spot-check a few names assert "list_rulebooks" in mcp.names @@ -221,12 +211,12 @@ def test_register_attaches_every_tool(): assert "update_preference" in mcp.names assert "create_project_rule" in mcp.names assert "move_rule" in mcp.names - # milestone 312: the sweep, and the stamp that answers it + # the sweep, and the stamp that answers it assert "rules_due_for_verification" in mcp.names assert "mark_rule_verified" in mcp.names - # milestone 323: what a rule used to say + # what a rule used to say assert "rule_history" in mcp.names - # milestone 414: a rule's home is its scope; nothing subscribes or mutes. + # a rule's home is its scope; nothing subscribes or mutes. for gone in ("subscribe_project_to_rulebook", "unsubscribe_project_from_rulebook", "suppress_rule_for_project", "unsuppress_rule_for_project", "suppress_topic_for_project", "unsuppress_topic_for_project"): @@ -245,8 +235,8 @@ def test_register_attaches_every_tool(): async def test_create_project_rule_passes_required_fields(): rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock = AsyncMock(return_value=rule) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail(): - from scribe.mcp.tools.rulebooks import create_project_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail(): + from grimoire.mcp.tools.rulebooks import create_project_rule await create_project_rule( project_id=42, statement="Always run migrations through alembic, not raw SQL.", @@ -264,8 +254,8 @@ async def test_create_project_rule_passes_required_fields(): async def test_create_project_rule_derives_title_from_statement(): rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock = AsyncMock(return_value=rule) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail(): - from scribe.mcp.tools.rulebooks import create_project_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail(): + from grimoire.mcp.tools.rulebooks import create_project_rule await create_project_rule( project_id=42, statement="Avoid auto-generated docstrings. Reviewers find them noise.", @@ -280,8 +270,8 @@ async def test_create_project_rule_derives_title_from_statement(): async def test_create_project_rule_uses_explicit_title_when_given(): rule = fake_rule(id=100, title="r", statement="s", topic_id=10) mock = AsyncMock(return_value=rule) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail(): - from scribe.mcp.tools.rulebooks import create_project_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail(): + from grimoire.mcp.tools.rulebooks import create_project_rule await create_project_rule( project_id=42, statement="anything", @@ -292,7 +282,7 @@ async def test_create_project_rule_uses_explicit_title_when_given(): assert kwargs["title"] == "no auto-docstrings" -# ── Typed edges between rules (milestone 307) ─────────────────────────── +# ── Typed edges between rules ─────────────────────────────────────────── @pytest.mark.asyncio async def test_relate_rules_forwards_the_kind_and_the_why(): @@ -302,8 +292,8 @@ async def test_relate_rules_forwards_the_kind_and_the_why(): relation = MagicMock() relation.to_dict.return_value = {"id": 9, "kind": "co_surfaces"} mock = AsyncMock(return_value=relation) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", mock): - from scribe.mcp.tools.rulebooks import relate_rules + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", mock): + from grimoire.mcp.tools.rulebooks import relate_rules out = await relate_rules( from_rule_id=46, to_rule_id=144, kind="co_surfaces", note="a stale channel tag and an unparseable version both read as " @@ -320,29 +310,29 @@ async def test_relate_rules_forwards_the_kind_and_the_why(): async def test_relate_rules_raises_when_either_end_is_not_yours(): """The service returns None when it cannot see both rules — a one-sided edge would surface a rule the caller has no business reading.""" - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", AsyncMock(return_value=None)): - from scribe.mcp.tools.rulebooks import relate_rules + from grimoire.mcp.tools.rulebooks import relate_rules with pytest.raises(ValueError, match="not found"): await relate_rules(from_rule_id=1, to_rule_id=2, kind="overrides") @pytest.mark.asyncio async def test_unrelate_rules_raises_when_the_edge_is_gone(): - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.remove_rule_relation", + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.remove_rule_relation", AsyncMock(return_value=False)): - from scribe.mcp.tools.rulebooks import unrelate_rules + from grimoire.mcp.tools.rulebooks import unrelate_rules with pytest.raises(ValueError, match="not found"): await unrelate_rules(relation_id=99) -# ── rule_history (milestone 323 step 3) ──────────────────────────────── +# ── rule_history ─────────────────────────────────────────────────────── def _fake_version(**over): """A RuleVersion-shaped stand-in. A real model instance rather than a MagicMock, because the tool calls `to_dict` and a mock would hand back another mock instead of failing.""" - from scribe.models.rule_version import RuleVersion + from grimoire.models.rule_version import RuleVersion from datetime import datetime, timezone defaults = { @@ -363,15 +353,15 @@ async def test_rule_history_lists_without_the_heavy_text(): thousands of characters, so a history carrying every field would cost more to read than the answer is worth.""" with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions", AsyncMock(return_value=[_fake_version(), _fake_version(id=4)]), ), patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=fake_rule( id=100, title="The runner has no bash", statement="s", topic_id=10, )), ): - from scribe.mcp.tools.rulebooks import rule_history + from grimoire.mcp.tools.rulebooks import rule_history out = await rule_history(rule_id=100) assert out["total"] == 2 @@ -385,10 +375,10 @@ async def test_rule_history_opens_one_version_in_full(): """Passing a version id switches from the index to the text — which is the whole reason the listing can afford to omit it.""" with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule_version", AsyncMock(return_value=_fake_version()), ): - from scribe.mcp.tools.rulebooks import rule_history + from grimoire.mcp.tools.rulebooks import rule_history out = await rule_history(rule_id=100, version_id=5) assert out["statement"] == "Use sh." @@ -397,19 +387,18 @@ async def test_rule_history_opens_one_version_in_full(): @pytest.mark.asyncio async def test_rule_history_says_an_empty_history_is_ordinary(): - """Most rules have never been reworded, and nothing was written before - milestone 323. Without this line an empty list reads as a lost history or - a broken tool.""" + """Most rules have never been reworded. Without this line an empty list + reads as a lost history or a broken tool.""" with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions", AsyncMock(return_value=[]), ), patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=fake_rule( id=100, title="The runner has no bash", statement="s", topic_id=10, )), ): - from scribe.mcp.tools.rulebooks import rule_history + from grimoire.mcp.tools.rulebooks import rule_history out = await rule_history(rule_id=100) assert out["total"] == 0 @@ -422,10 +411,10 @@ async def test_rule_history_raises_when_the_rule_is_not_yours(): turn that into an empty history — which would read as "this rule has no past" rather than "this is not your rule".""" with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions", AsyncMock(return_value=None), ): - from scribe.mcp.tools.rulebooks import rule_history + from grimoire.mcp.tools.rulebooks import rule_history with pytest.raises(ValueError, match="rule 100 not found"): await rule_history(rule_id=100) @@ -433,10 +422,10 @@ async def test_rule_history_raises_when_the_rule_is_not_yours(): @pytest.mark.asyncio async def test_rule_history_raises_for_a_version_on_another_rule(): with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule_version", AsyncMock(return_value=None), ): - from scribe.mcp.tools.rulebooks import rule_history + from grimoire.mcp.tools.rulebooks import rule_history with pytest.raises(ValueError, match="version 5 not found"): await rule_history(rule_id=100, version_id=5) @@ -446,7 +435,7 @@ def test_rule_history_docstring_says_what_a_version_HOLDS(): edit REPLACED, not the text it introduced. Read the other way, every diff comes out backwards — so the docstring has to say it, and this is the guard against a later tidy-up dropping the line.""" - from scribe.mcp.tools.rulebooks import rule_history + from grimoire.mcp.tools.rulebooks import rule_history doc = rule_history.__doc__ or "" assert "REPLACED" in doc @@ -465,9 +454,9 @@ async def test_move_rule_passes_one_destination_and_returns_the_detail(): moved = fake_rule(id=94, topic_id=12, project_id=None) move = AsyncMock(return_value=moved) detail = AsyncMock(return_value={"id": 94, "topic_id": 12, "project_id": None}) - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.move_rule", move), \ - patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail", detail): - from scribe.mcp.tools.rulebooks import move_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.move_rule", move), \ + patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.rule_detail", detail): + from grimoire.mcp.tools.rulebooks import move_rule out = await move_rule(rule_id=94, topic_id=12) assert move.await_args.args[0] == 94 assert move.await_args.kwargs == {"topic_id": 12, "project_id": 0} @@ -476,24 +465,24 @@ async def test_move_rule_passes_one_destination_and_returns_the_detail(): @pytest.mark.asyncio async def test_move_rule_on_someone_elses_rule_is_not_found(): - with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.move_rule", AsyncMock(return_value=None)): - from scribe.mcp.tools.rulebooks import move_rule + with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.move_rule", AsyncMock(return_value=None)): + from grimoire.mcp.tools.rulebooks import move_rule with pytest.raises(ValueError, match="not found"): await move_rule(rule_id=94, project_id=3) -# ── rule_outcome: what a rule actually changed (#4212, milestone 419) ───── +# ── rule_outcome: what a rule actually changed ─────────────────────────── @pytest.mark.asyncio async def test_rule_outcome_records_an_application(): rule = fake_rule(id=156, title="`dev` is home") with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=rule), ), patch( - "scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock() + "grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock() ) as rec: - from scribe.mcp.tools.rulebooks import rule_outcome + from grimoire.mcp.tools.rulebooks import rule_outcome out = await rule_outcome(rule_id=156, outcome="applied") assert out["outcome"] == "applied" and out["recorded"] is True assert out["why"] is None @@ -505,12 +494,12 @@ async def test_rule_outcome_records_an_application(): async def test_rule_outcome_records_a_departure_with_its_reason(): rule = fake_rule(id=156, title="`dev` is home") with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=rule), ), patch( - "scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock() + "grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock() ) as rec: - from scribe.mcp.tools.rulebooks import rule_outcome + from grimoire.mcp.tools.rulebooks import rule_outcome out = await rule_outcome( rule_id=156, outcome="departed", why="the operator asked for main" ) @@ -525,12 +514,12 @@ async def test_a_departure_without_a_reason_is_refused_at_the_door(): and "it cannot be told from a miss" is the argument that lands.""" rule = fake_rule(id=156, title="`dev` is home") with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=rule), ), patch( - "scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock() + "grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock() ) as rec: - from scribe.mcp.tools.rulebooks import rule_outcome + from grimoire.mcp.tools.rulebooks import rule_outcome with pytest.raises(ValueError, match="departure needs its reason"): await rule_outcome(rule_id=156, outcome="departed", why=" ") rec.assert_not_called() @@ -544,12 +533,12 @@ async def test_there_is_no_way_to_report_having_ignored_a_rule(): error rather than a row that would read as measurement.""" rule = fake_rule(id=156, title="`dev` is home") with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=rule), ), patch( - "scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock() + "grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock() ) as rec: - from scribe.mcp.tools.rulebooks import rule_outcome + from grimoire.mcp.tools.rulebooks import rule_outcome for bogus in ("ignored", "skipped", "read", ""): with pytest.raises(ValueError, match="must be 'applied' or 'departed'"): await rule_outcome(rule_id=156, outcome=bogus) @@ -562,12 +551,12 @@ async def test_rule_outcome_refuses_a_rule_the_caller_cannot_see(): the existence of someone else's rule, and nothing is recorded against an id the caller has no claim on.""" with patch( - "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", + "grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule", AsyncMock(return_value=None), ), patch( - "scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock() + "grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock() ) as rec: - from scribe.mcp.tools.rulebooks import rule_outcome + from grimoire.mcp.tools.rulebooks import rule_outcome with pytest.raises(ValueError, match="rule 999 not found"): await rule_outcome(rule_id=999, outcome="applied") rec.assert_not_called() @@ -577,8 +566,8 @@ def test_rule_outcome_is_registered_and_is_not_read_only(): """Its whole effect is a write, and `why` is prose the agent authored — a read-scoped key that can put text in the operator's database is not read-scoped, whatever table it lands in.""" - from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS - from scribe.mcp.tools import rulebooks as mod + from grimoire.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS + from grimoire.mcp.tools import rulebooks as mod mcp = FakeMCP() mod.register(mcp) assert "rule_outcome" in mcp.names diff --git a/tests/test_mcp_tool_search.py b/tests/test_mcp_tool_search.py index 2fff7a76..6f1f8cc1 100644 --- a/tests/test_mcp_tool_search.py +++ b/tests/test_mcp_tool_search.py @@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp._context import _user_id_ctx -from scribe.mcp.tools.search import search +from grimoire.mcp._context import _user_id_ctx +from grimoire.mcp.tools.search import search from tests.helpers import fake_note @@ -29,7 +29,7 @@ async def test_fable_search_returns_repackaged_results(): _user_id_ctx.set(7) fake = fake_note(id=1, title="kafka rebalance", is_task=False, body="HPA details", tags=["ops"]) with patch( - "scribe.mcp.tools.search.semantic_search_notes", + "grimoire.mcp.tools.search.semantic_search_notes", AsyncMock(return_value=[(0.93, fake)]), ): out = await search(q="kafka") @@ -52,7 +52,7 @@ async def test_fable_search_returns_repackaged_results(): @pytest.mark.asyncio async def test_search_shows_the_passage_that_matched_not_the_opening(): - """The point of #4243. A record can rank on its sixth paragraph; showing + """A record can rank on its sixth paragraph; showing its first is showing the caller a span the search already judged less relevant, and letting them decide from it.""" _user_id_ctx.set(7) @@ -65,7 +65,7 @@ async def test_search_shows_the_passage_that_matched_not_the_opening(): } return [(0.5, fake)] - with patch("scribe.mcp.tools.search.semantic_search_notes", _search): + with patch("grimoire.mcp.tools.search.semantic_search_notes", _search): out = await search(q="answer") r = out["results"][0] assert r["excerpt"] == "THE ANSWER IS 04775c3." @@ -83,7 +83,7 @@ async def test_a_result_says_whether_its_excerpt_is_the_match_or_the_opening(): _user_id_ctx.set(7) fake = fake_note(id=1, title="t", body="short body") with patch( - "scribe.mcp.tools.search.semantic_search_notes", + "grimoire.mcp.tools.search.semantic_search_notes", AsyncMock(return_value=[(0.5, fake)]), ): out = await search(q="x") @@ -98,7 +98,7 @@ async def test_a_long_excerpt_keeps_both_ends_and_says_how_much_went(): body = "OPENING. " + ("m" * 3000) + " CLOSING." fake = fake_note(id=1, title="t", body=body) with patch( - "scribe.mcp.tools.search.semantic_search_notes", + "grimoire.mcp.tools.search.semantic_search_notes", AsyncMock(return_value=[(0.5, fake)]), ): out = await search(q="x") @@ -115,7 +115,7 @@ async def test_a_short_record_arrives_whole_and_unmarked(): _user_id_ctx.set(7) fake = fake_note(id=1, title="t", body="all of it") with patch( - "scribe.mcp.tools.search.semantic_search_notes", + "grimoire.mcp.tools.search.semantic_search_notes", AsyncMock(return_value=[(0.5, fake)]), ): out = await search(q="x") @@ -131,10 +131,10 @@ async def test_fable_search_content_type_filters_at_service_layer(): specific kinds arrived — it is BROAD here (any non-task, snippets and lessons included) while the web facet of the same name is narrow. Pinning it stops a later tidy-up from silently removing snippets from every caller - that already asks this way (#4250).""" + that already asks this way.""" _user_id_ctx.set(7) mock_search = AsyncMock(return_value=[]) - with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search): + with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search): await search(q="x", content_type="task") assert mock_search.call_args.kwargs["is_task"] is True assert mock_search.call_args.kwargs.get("task_kind") is None @@ -149,7 +149,7 @@ async def test_fable_search_content_type_filters_at_service_layer(): assert mock_search.call_args.kwargs["is_task"] is None -# --- the specific kinds: the engine already supported them (#4250) ----------- +# --- the specific kinds the engine supports ---------------------------------- @pytest.mark.asyncio @@ -168,12 +168,12 @@ async def test_fable_search_content_type_filters_at_service_layer(): async def test_each_specific_kind_reaches_the_engine_filter_it_names( content_type, expected ): - """`note_type` and `task_kind` were parameters of the search all along — - what was missing was a way to ask for them from the agent's door. Asking + """`note_type` and `task_kind` are parameters of the search, and the + agent's door passes them through. Asking for a snippet must narrow to snippets, not merely to non-tasks.""" _user_id_ctx.set(7) mock_search = AsyncMock(return_value=[]) - with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search): + with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search): await search(q="x", content_type=content_type) kwargs = mock_search.call_args.kwargs for key, value in expected.items(): @@ -181,17 +181,16 @@ async def test_each_specific_kind_reaches_the_engine_filter_it_names( def test_the_vocabulary_is_derived_from_the_facet_table_not_recopied(): - """#3161's property, held at this door too: adding a kind to `_FACETS` is - one edit. A hand-kept list here is exactly how the agent's search came to - offer two kinds while the web's offered nine.""" - from scribe.services.knowledge import FACET_TYPES, content_type_filters + """Adding a kind to `_FACETS` is one edit, at this door too. A hand-kept + list here lets the agent's search offer fewer kinds than the web's.""" + from grimoire.services.knowledge import FACET_TYPES, content_type_filters for facet in FACET_TYPES: filters = content_type_filters(facet) # raises if a kind is unreachable assert "is_task" in filters, facet # The guard can fail: a name absent from the table is refused, so this is - # membership in the table and not "every string works" (#167). + # membership in the table and not "every string works". with pytest.raises(ValueError): content_type_filters("a-kind-that-is-not-in-the-facet-table") @@ -204,7 +203,7 @@ async def test_an_unknown_content_type_is_refused_rather_than_returning_nothing( instead of falling through to a filter that matches no row.""" _user_id_ctx.set(7) mock_search = AsyncMock(return_value=[]) - with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search): + with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search): with pytest.raises(ValueError) as err: await search(q="x", content_type="snippets") # plural typo mock_search.assert_not_awaited() @@ -215,10 +214,10 @@ async def test_an_unknown_content_type_is_refused_rather_than_returning_nothing( @pytest.mark.asyncio async def test_the_docstring_names_every_kind_the_tool_accepts(): - """The docstring IS the agent-facing contract (#2846) — a filter an agent + """The docstring IS the agent-facing contract — a filter an agent has not been told about is unreachable however well it is wired.""" - from scribe.mcp.tools.search import search as search_tool - from scribe.services.knowledge import FACET_TYPES + from grimoire.mcp.tools.search import search as search_tool + from grimoire.services.knowledge import FACET_TYPES doc = search_tool.__doc__ or "" for facet in FACET_TYPES: @@ -229,12 +228,12 @@ async def test_the_docstring_names_every_kind_the_tool_accepts(): @pytest.mark.asyncio async def test_an_explicit_search_reaches_a_lesson_from_any_project(): - """The wiring half of milestone 385 step 3. + """An explicit search reaches lessons in every project. A lesson records an insight that transfers, so a project filter that hid it would hide it precisely on the project that has not learned it yet. This is the EXPLICIT search — the operator asked — so it opts in; the unasked-for - injection arms decide their own budget separately (step 5). + injection arms decide their own budget separately. Asserted on the kwarg rather than on results, because what can regress here is the wiring: the service grew the capability and a call site that never @@ -243,7 +242,7 @@ async def test_an_explicit_search_reaches_a_lesson_from_any_project(): """ _user_id_ctx.set(7) mock_search = AsyncMock(return_value=[]) - with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search): + with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search): await search(q="x", project_id=3) assert mock_search.call_args.kwargs["include_global_kinds"] is True @@ -253,7 +252,7 @@ async def test_an_explicit_search_reaches_a_lesson_from_any_project(): async def test_fable_search_limit_is_clamped(): _user_id_ctx.set(7) mock_search = AsyncMock(return_value=[]) - with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search): + with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search): await search(q="x", limit=999) assert mock_search.call_args.kwargs["limit"] == 50 @@ -269,12 +268,12 @@ async def test_fable_search_limit_is_clamped(): (0, {"everywhere": True}), ]) async def test_rule_search_scopes_to_the_project_it_is_given(project_id, scope): - """With a project: global rules plus that project's (milestone 414). + """With a project: global rules plus that project's. Without one: every rule, because an unscoped "is there a rule about this" is asking the whole rulebook — unlike a hook, which speaks unasked.""" _user_id_ctx.set(7) found = AsyncMock(return_value=[]) - with patch("scribe.mcp.tools.search.semantic_search_rules", found): + with patch("grimoire.mcp.tools.search.semantic_search_rules", found): await search(q="release tagging", content_type="rule", project_id=project_id) kwargs = found.await_args.kwargs assert {k: kwargs[k] for k in scope} == scope @@ -282,7 +281,7 @@ async def test_rule_search_scopes_to_the_project_it_is_given(project_id, scope): def test_a_milestone_is_embedded_by_what_it_is_for_then_its_plan(): - from scribe.services.embeddings import milestone_document + from grimoire.services.embeddings import milestone_document assert milestone_document("M3", "metadata providers", "## Goal\nx") == ( "M3 — metadata providers", "metadata providers\n\n## Goal\nx") @@ -293,14 +292,13 @@ def test_a_milestone_is_embedded_by_what_it_is_for_then_its_plan(): @pytest.mark.asyncio async def test_milestone_search_is_its_own_shape_and_scopes_to_the_project(): - """milestone 415: 'is there already a plan for this?' has a tool.""" + """'Is there already a plan for this?' has a tool.""" from unittest.mock import MagicMock _user_id_ctx.set(7) # `body` is a REAL string, not left to MagicMock's attribute autovivication: # the result now reads it, and a mock body would make `matched` a mock - # object and `body_length` zero while the assertion still looked green - # (lesson #2833). + # object and `body_length` zero while the assertion still looked green. ms = MagicMock(id=339, title="M3 — Metadata", description="works, editions", status="active", project_id=30, body="Step 4 — the metadata editions table.") @@ -315,15 +313,15 @@ async def test_milestone_search_is_its_own_shape_and_scopes_to_the_project(): } return [(0.81, ms)] - with patch("scribe.mcp.tools.search.semantic_search_milestones", + with patch("grimoire.mcp.tools.search.semantic_search_milestones", _milestone_search), \ - patch("scribe.services.milestones.get_project_milestone_summary", summary): + patch("grimoire.services.milestones.get_project_milestone_summary", summary): out = await search(q="book metadata", content_type="milestone", project_id=30) assert seen["project_id"] == 30 assert out["results"] == [{ "id": 339, "title": "M3 — Metadata", "description": "works, editions", # The plan body stays out; the passage that matched comes along, and - # says which of the two it is (#4243). + # says which of the two it is. "matched": "Step 4 — the metadata editions table.", "matched_is": "matched_passage", "body_length": len("Step 4 — the metadata editions table."), @@ -332,7 +330,7 @@ async def test_milestone_search_is_its_own_shape_and_scopes_to_the_project(): }] -# --- systems: the charter as an answer, not a filter (#4251) ----------------- +# --- systems: the charter as an answer, not a filter ------------------------- @pytest.mark.asyncio @@ -340,7 +338,7 @@ async def test_system_search_is_its_own_shape_and_carries_the_matched_passage(): """A System's charter runs to several hundred words and a result shows its NAME — so a match on the paragraph that actually decides where a record belongs would be previewed by two words that cannot. The passage comes - along, marked as the passage (#4243).""" + along, marked as the passage.""" _user_id_ctx.set(7) charter = ( "Opening sentence about the area in general. " @@ -349,7 +347,7 @@ async def test_system_search_is_its_own_shape_and_carries_the_matched_passage(): ) fake = MagicMock(id=3, project_id=2, description=charter) # `name` is MagicMock's own constructor kwarg — passed in, it names the - # mock and leaves `.name` a mock object, which is note #2833's whole point. + # mock and leaves `.name` a mock object. fake.name = "Retrieval & recall" async def _system_search(uid, q, **kwargs): @@ -358,7 +356,7 @@ async def test_system_search_is_its_own_shape_and_carries_the_matched_passage(): } return [(0.81, fake)] - with patch("scribe.mcp.tools.search.semantic_search_systems", _system_search): + with patch("grimoire.mcp.tools.search.semantic_search_systems", _system_search): out = await search(q="where does retrieval telemetry go?", content_type="system", project_id=2) @@ -385,12 +383,12 @@ async def test_system_search_scopes_to_the_project_it_was_given(): answer to "where does this belong here?".""" _user_id_ctx.set(7) mock = AsyncMock(return_value=[]) - with patch("scribe.mcp.tools.search.semantic_search_systems", mock): + with patch("grimoire.mcp.tools.search.semantic_search_systems", mock): await search(q="x", content_type="system", project_id=30) assert mock.call_args.kwargs["project_id"] == 30 mock.reset_mock() - with patch("scribe.mcp.tools.search.semantic_search_systems", mock): + with patch("grimoire.mcp.tools.search.semantic_search_systems", mock): await search(q="x", content_type="system") assert mock.call_args.kwargs["project_id"] is None @@ -402,8 +400,8 @@ async def test_a_system_search_does_not_go_through_the_note_search(): _user_id_ctx.set(7) notes = AsyncMock(return_value=[]) with ( - patch("scribe.mcp.tools.search.semantic_search_notes", notes), - patch("scribe.mcp.tools.search.semantic_search_systems", AsyncMock(return_value=[])), + patch("grimoire.mcp.tools.search.semantic_search_notes", notes), + patch("grimoire.mcp.tools.search.semantic_search_systems", AsyncMock(return_value=[])), ): await search(q="x", content_type="system") notes.assert_not_awaited() diff --git a/tests/test_mcp_tool_snippets.py b/tests/test_mcp_tool_snippets.py index c7bb5b54..63283327 100644 --- a/tests/test_mcp_tool_snippets.py +++ b/tests/test_mcp_tool_snippets.py @@ -10,7 +10,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user") @pytest.mark.asyncio async def test_create_snippet_requires_name_and_code(): - from scribe.mcp.tools.snippets import create_snippet + from grimoire.mcp.tools.snippets import create_snippet with pytest.raises(ValueError): await create_snippet(name="", code="x") with pytest.raises(ValueError): @@ -20,10 +20,10 @@ async def test_create_snippet_requires_name_and_code(): @pytest.mark.asyncio async def test_create_snippet_records_and_returns_parsed(): created = fake_snippet() - with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.services.snippets.create_snippet", + with patch("grimoire.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ + patch("grimoire.services.snippets.create_snippet", AsyncMock(return_value=created)) as mock_create: - from scribe.mcp.tools.snippets import create_snippet + from grimoire.mcp.tools.snippets import create_snippet out = await create_snippet( name="debounce", code="return 1", language="js", when_to_use="rate-limit a callback", @@ -37,11 +37,11 @@ async def test_create_snippet_records_and_returns_parsed(): @pytest.mark.asyncio async def test_create_snippet_dedup_blocks_and_labels_snippet(): dup = MagicMock(id=99) - with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=dup)), \ - patch("scribe.services.dedup.duplicate_response", + with patch("grimoire.services.dedup.find_duplicate_note", AsyncMock(return_value=dup)), \ + patch("grimoire.services.dedup.duplicate_response", MagicMock(return_value={"duplicate": True, "existing_id": 99})) as mock_resp, \ - patch("scribe.services.snippets.create_snippet", AsyncMock()) as mock_create: - from scribe.mcp.tools.snippets import create_snippet + patch("grimoire.services.snippets.create_snippet", AsyncMock()) as mock_create: + from grimoire.mcp.tools.snippets import create_snippet out = await create_snippet(name="debounce", code="return 1") assert out["duplicate"] is True mock_create.assert_not_awaited() @@ -50,16 +50,16 @@ async def test_create_snippet_dedup_blocks_and_labels_snippet(): @pytest.mark.asyncio async def test_get_snippet_not_found_raises(): - with patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)): - from scribe.mcp.tools.snippets import get_snippet + with patch("grimoire.services.snippets.get_snippet", AsyncMock(return_value=None)): + from grimoire.mcp.tools.snippets import get_snippet with pytest.raises(ValueError): await get_snippet(123) @pytest.mark.asyncio async def test_update_snippet_missing_raises(): - with patch("scribe.services.snippets.update_snippet", AsyncMock(return_value=None)): - from scribe.mcp.tools.snippets import update_snippet + with patch("grimoire.services.snippets.update_snippet", AsyncMock(return_value=None)): + from grimoire.mcp.tools.snippets import update_snippet with pytest.raises(ValueError): await update_snippet(123, name="x") @@ -69,9 +69,9 @@ async def test_update_snippet_empty_string_clears_a_field(): # An omitted field must stay None ("leave alone"), but an explicit empty # string has to reach the service as "" so a stale field can be removed. updated = fake_snippet() - with patch("scribe.services.snippets.update_snippet", + with patch("grimoire.services.snippets.update_snippet", AsyncMock(return_value=updated)) as mock_update: - from scribe.mcp.tools.snippets import update_snippet + from grimoire.mcp.tools.snippets import update_snippet await update_snippet(1, signature="") kwargs = mock_update.await_args.kwargs assert kwargs["signature"] == "" # cleared @@ -80,13 +80,13 @@ async def test_update_snippet_empty_string_clears_a_field(): @pytest.mark.asyncio async def test_update_snippet_project_id_conventions(): - from scribe.services import snippets as snippets_svc + from grimoire.services import snippets as snippets_svc updated = fake_snippet() cases = {0: snippets_svc.UNSET, -1: None, 5: 5} for given, expected in cases.items(): - with patch("scribe.services.snippets.update_snippet", + with patch("grimoire.services.snippets.update_snippet", AsyncMock(return_value=updated)) as mock_update: - from scribe.mcp.tools.snippets import update_snippet + from grimoire.mcp.tools.snippets import update_snippet await update_snippet(1, project_id=given) assert mock_update.await_args.kwargs["project_id"] is expected @@ -96,16 +96,16 @@ async def test_create_and_update_pass_locations_through(): locs = [{"repo": "a", "path": "a.py", "symbol": "f"}, {"repo": "b", "path": "b.py", "symbol": "g"}] created = fake_snippet() - with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ - patch("scribe.services.snippets.create_snippet", + with patch("grimoire.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ + patch("grimoire.services.snippets.create_snippet", AsyncMock(return_value=created)) as mock_create: - from scribe.mcp.tools.snippets import create_snippet + from grimoire.mcp.tools.snippets import create_snippet await create_snippet(name="f", code="x", locations=locs) assert mock_create.await_args.kwargs["locations"] == locs - with patch("scribe.services.snippets.update_snippet", + with patch("grimoire.services.snippets.update_snippet", AsyncMock(return_value=created)) as mock_update: - from scribe.mcp.tools.snippets import update_snippet + from grimoire.mcp.tools.snippets import update_snippet await update_snippet(1, locations=locs) assert mock_update.await_args.kwargs["locations"] == locs @@ -115,50 +115,50 @@ async def test_update_snippet_read_only_share_says_why(): """A viewer grant must be refused with the REAL reason. "Not found" would be a lie about a record the caller can plainly open, and would send an agent hunting for a missing id instead of recording its own version.""" - with patch("scribe.services.snippets.update_snippet", + with patch("grimoire.services.snippets.update_snippet", AsyncMock(side_effect=PermissionError( "snippet 1 is shared with you read-only — ask its owner for " "edit access, or record your own version"))): - from scribe.mcp.tools.snippets import update_snippet + from grimoire.mcp.tools.snippets import update_snippet with pytest.raises(ValueError, match="read-only"): await update_snippet(1, name="x") @pytest.mark.asyncio async def test_merge_snippets_read_only_target_says_why(): - with patch("scribe.services.snippets.merge_snippets", + with patch("grimoire.services.snippets.merge_snippets", AsyncMock(side_effect=PermissionError( "snippet 1 is shared with you read-only — you can't merge " "into a record you can't edit"))): - from scribe.mcp.tools.snippets import merge_snippets + from grimoire.mcp.tools.snippets import merge_snippets with pytest.raises(ValueError, match="read-only"): await merge_snippets(target_id=1, source_ids=[2]) @pytest.mark.asyncio async def test_delete_snippet_retires_or_raises(): - from scribe.mcp.tools.snippets import delete_snippet + from grimoire.mcp.tools.snippets import delete_snippet doomed = MagicMock() doomed.title = "debounce helper" - # The confirmation names what went (#3273) — after the delete there is + # The confirmation names what went — after the delete there is # nothing left to look the title up from. - with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=True)), \ - patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=doomed)): + with patch("grimoire.services.snippets.delete_snippet", AsyncMock(return_value=True)), \ + patch("grimoire.services.snippets.get_snippet", AsyncMock(return_value=doomed)): assert await delete_snippet(1) == { "deleted": True, "id": 1, "title": "debounce helper", } - with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=False)), \ - patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)): + with patch("grimoire.services.snippets.delete_snippet", AsyncMock(return_value=False)), \ + patch("grimoire.services.snippets.get_snippet", AsyncMock(return_value=None)): with pytest.raises(ValueError): await delete_snippet(404) @pytest.mark.asyncio async def test_list_snippets_defaults_to_every_project(): - with patch("scribe.services.snippets.list_snippets", + with patch("grimoire.services.snippets.list_snippets", AsyncMock(return_value=([], 0))) as mock_list: - from scribe.mcp.tools.snippets import list_snippets + from grimoire.mcp.tools.snippets import list_snippets await list_snippets(q="debounce") assert mock_list.await_args.kwargs["project_id"] is None await list_snippets(q="debounce", project_id=3) @@ -167,7 +167,7 @@ async def test_list_snippets_defaults_to_every_project(): @pytest.mark.asyncio async def test_merge_snippets_requires_a_source(): - from scribe.mcp.tools.snippets import merge_snippets + from grimoire.mcp.tools.snippets import merge_snippets with pytest.raises(ValueError): await merge_snippets(target_id=1, source_ids=[]) with pytest.raises(ValueError): @@ -177,9 +177,9 @@ async def test_merge_snippets_requires_a_source(): @pytest.mark.asyncio async def test_merge_snippets_returns_survivor_and_merged_ids(): survivor = fake_snippet() - with patch("scribe.services.snippets.merge_snippets", + with patch("grimoire.services.snippets.merge_snippets", AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge: - from scribe.mcp.tools.snippets import merge_snippets + from grimoire.mcp.tools.snippets import merge_snippets out = await merge_snippets(target_id=1, source_ids=[2, 3, 1]) assert out["merged_ids"] == [2, 3] assert out["snippet"]["name"] == "debounce" @@ -190,14 +190,14 @@ async def test_merge_snippets_returns_survivor_and_merged_ids(): @pytest.mark.asyncio async def test_merge_snippets_not_found_raises(): - with patch("scribe.services.snippets.merge_snippets", AsyncMock(return_value=None)): - from scribe.mcp.tools.snippets import merge_snippets + with patch("grimoire.services.snippets.merge_snippets", AsyncMock(return_value=None)): + from grimoire.mcp.tools.snippets import merge_snippets with pytest.raises(ValueError): await merge_snippets(target_id=1, source_ids=[2]) def test_register_attaches_all_tools(): - from scribe.mcp.tools import snippets + from grimoire.mcp.tools import snippets mcp = FakeMCP() snippets.register(mcp) diff --git a/tests/test_mcp_tool_systems.py b/tests/test_mcp_tool_systems.py index b60c543a..83302719 100644 --- a/tests/test_mcp_tool_systems.py +++ b/tests/test_mcp_tool_systems.py @@ -5,7 +5,7 @@ import pytest from tests.helpers import fake_note, fake_system, fake_task -# The name assessment both doors run before minting (milestone 307). A test +# The name assessment both doors run before minting. A test # that patches systems_svc wholesale must stub it, or the awaited MagicMock # raises — this shape is the "nothing matched" answer. _NO_MATCH = {"duplicate": None, "canonical": None} @@ -13,11 +13,11 @@ _NO_MATCH = {"duplicate": None, "canonical": None} @pytest.mark.asyncio async def test_create_system_returns_dict(): - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.assess_system_name = AsyncMock(return_value=_NO_MATCH) svc.create_system = AsyncMock(return_value=fake_system(name="Reader")) - from scribe.mcp.tools.systems import create_system + from grimoire.mcp.tools.systems import create_system result = await create_system(project_id=5, name="Reader", description="pdf reader") assert result["name"] == "Reader" # An unmatched name is a project-specific area: created, no offer, no fuss. @@ -26,11 +26,11 @@ async def test_create_system_returns_dict(): @pytest.mark.asyncio async def test_create_system_no_access_raises(): - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.assess_system_name = AsyncMock(return_value=_NO_MATCH) svc.create_system = AsyncMock(return_value=None) - from scribe.mcp.tools.systems import create_system + from grimoire.mcp.tools.systems import create_system with pytest.raises(ValueError): await create_system(project_id=5, name="Reader") @@ -44,10 +44,10 @@ async def test_create_system_applies_an_exact_area_and_offers_a_similar_one(): judgment call, so it is only OFFERED: applying it silently is how a cross-project rule ends up surfacing in the wrong project. """ - from scribe.mcp.tools.systems import create_system + from grimoire.mcp.tools.systems import create_system - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.assess_system_name = AsyncMock(return_value={ "duplicate": None, "canonical": {"id": 3, "name": "CI & Release", "basis": "exact"}, @@ -57,8 +57,8 @@ async def test_create_system_applies_an_exact_area_and_offers_a_similar_one(): assert svc.create_system.await_args.kwargs["canonical_id"] == 3 assert "canonical_note" in exact and "canonical_suggestion" not in exact - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.assess_system_name = AsyncMock(return_value={ "duplicate": None, "canonical": {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33}, @@ -72,9 +72,9 @@ async def test_create_system_applies_an_exact_area_and_offers_a_similar_one(): @pytest.mark.asyncio async def test_create_system_duplicate_names_the_existing_one_and_creates_nothing(): - from scribe.mcp.tools.systems import create_system - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + from grimoire.mcp.tools.systems import create_system + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.assess_system_name = AsyncMock(return_value={ "duplicate": {"id": 4, "name": "Reader"}, "canonical": None, }) @@ -90,11 +90,11 @@ async def test_get_system_splits_records_by_kind(): issue = fake_task(id=10, task_kind="issue", milestone_id=None) work = fake_task(id=11, milestone_id=None) note = fake_note(id=12, status=None, milestone_id=None) - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.get_system = AsyncMock(return_value=fake_system(id=3)) svc.list_records_for_system = AsyncMock(return_value=[issue, work, note]) - from scribe.mcp.tools.systems import get_system + from grimoire.mcp.tools.systems import get_system result = await get_system(system_id=3) assert [r["id"] for r in result["issues"]] == [10] assert [r["id"] for r in result["tasks"]] == [11] @@ -104,12 +104,12 @@ async def test_get_system_splits_records_by_kind(): @pytest.mark.asyncio async def test_path_patterns_reach_the_service_from_both_tools(): """Omitted means unchanged; an empty list is a value — it clears them.""" - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.assess_system_name = AsyncMock(return_value=_NO_MATCH) svc.create_system = AsyncMock(return_value=fake_system(name="Billing")) svc.update_system = AsyncMock(return_value=fake_system(name="Billing")) - from scribe.mcp.tools.systems import create_system, update_system + from grimoire.mcp.tools.systems import create_system, update_system await create_system(project_id=5, name="Billing", path_patterns=["src/billing"]) assert svc.create_system.await_args.kwargs["path_patterns"] == ["src/billing"] await update_system(system_id=1, name="Billing") @@ -120,10 +120,10 @@ async def test_path_patterns_reach_the_service_from_both_tools(): @pytest.mark.asyncio async def test_update_system_not_found_raises(): - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.update_system = AsyncMock(return_value=None) - from scribe.mcp.tools.systems import update_system + from grimoire.mcp.tools.systems import update_system with pytest.raises(ValueError): await update_system(system_id=99, name="x") @@ -131,13 +131,13 @@ async def test_update_system_not_found_raises(): @pytest.mark.asyncio async def test_create_task_issue_sets_kind_provenance_and_systems(): note = MagicMock(); note.id = 50; note.to_dict.return_value = {"id": 50, "task_kind": "issue"} - with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \ - patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc: + with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \ + patch("grimoire.mcp.tools.tasks.systems_svc") as systems_svc: notes_svc.create_note = AsyncMock(return_value=note) systems_svc.set_record_systems = AsyncMock(return_value=[2, 3]) systems_svc.list_record_systems = AsyncMock(return_value=[]) - from scribe.mcp.tools.tasks import create_task + from grimoire.mcp.tools.tasks import create_task result = await create_task(title="bug", kind="issue", system_ids=[2, 3], arose_from_id=9) _, kwargs = notes_svc.create_note.call_args assert kwargs["task_kind"] == "issue" @@ -150,9 +150,9 @@ async def test_create_task_issue_sets_kind_provenance_and_systems(): async def test_untagged_hint_names_the_projects_systems(): sys_a = MagicMock(); sys_a.id = 1; sys_a.name = "workers" sys_b = MagicMock(); sys_b.id = 2; sys_b.name = "scrape-pipeline" - with patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.systems_svc") as svc: svc.list_systems = AsyncMock(return_value=[sys_a, sys_b]) - from scribe.mcp.tools.systems import untagged_systems_hint + from grimoire.mcp.tools.systems import untagged_systems_hint hint = await untagged_systems_hint(1, 5) assert "workers" in hint and "scrape-pipeline" in hint assert "system_ids" in hint @@ -161,9 +161,9 @@ async def test_untagged_hint_names_the_projects_systems(): @pytest.mark.asyncio async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open(): - from scribe.mcp.tools.systems import untagged_systems_hint - with patch("scribe.mcp.tools.systems.systems_svc") as svc, \ - patch("scribe.mcp.tools.systems.notes_svc") as notes: + from grimoire.mcp.tools.systems import untagged_systems_hint + with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \ + patch("grimoire.mcp.tools.systems.notes_svc") as notes: # Zero Systems in a YOUNG project (below the bootstrap threshold): # the mild question stays proportionate — it must prompt the FIRST # create_system, not go silent. @@ -171,7 +171,7 @@ async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open(): notes.list_notes = AsyncMock(return_value=([], 3)) hint = await untagged_systems_hint(1, 5) assert "no Systems yet" in hint and "create_system" in hint - with patch("scribe.mcp.tools.systems.systems_svc") as svc: + with patch("grimoire.mcp.tools.systems.systems_svc") as svc: # A hint must never break a create — DB failure degrades to no hint. svc.list_systems = AsyncMock(side_effect=RuntimeError("db down")) assert await untagged_systems_hint(1, 5) is None @@ -179,18 +179,18 @@ async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open(): @pytest.mark.asyncio async def test_untagged_hint_escalates_in_a_mature_zero_systems_project(): - """#2683: at 282 records and zero Systems (Minstrel), the generic question - had demonstrably never converted. The escalated ask must carry the + """In a mature project with zero Systems the generic question does not + convert. The escalated ask must carry the project's OWN evidence — record count and recent titles — and demand a concrete deliverable, because that is the property separating the nudges that convert from the prose that doesn't.""" - from scribe.mcp.tools.systems import untagged_systems_hint + from grimoire.mcp.tools.systems import untagged_systems_hint recent = [fake_note(title="Fix scrape retry backoff"), fake_note(title="Worker pool sizing")] - with patch("scribe.mcp.tools.systems.systems_svc") as svc, \ - patch("scribe.mcp.tools.systems.notes_svc") as notes: + with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \ + patch("grimoire.mcp.tools.systems.notes_svc") as notes: svc.list_systems = AsyncMock(return_value=[]) notes.list_notes = AsyncMock(return_value=(recent, 282)) - # The standard names come from the GLOBAL catalog now (milestone 307), + # The standard names come from the GLOBAL catalog, # not a module constant — so the ask reads them through the service. # That the SEEDED vocabulary is these eight is migration 0087's # business, asserted against a real database in the inception @@ -203,7 +203,7 @@ async def test_untagged_hint_escalates_in_a_mature_zero_systems_project(): assert "282 records" in hint assert "Fix scrape retry backoff" in hint # the project's own evidence assert "3-6" in hint and "create_system" in hint - # NOT an approval flow (#2798): the agent mints directly, and the + # NOT an approval flow: the agent mints directly, and the # standard cross-project names carry the consistency instead. assert "without asking permission" in hint assert "propose" not in hint and "confirmed" not in hint @@ -218,10 +218,10 @@ async def test_bootstrap_ask_still_asks_when_the_catalog_is_unreachable(): catalog read fails (or an install has an empty one), the ask must still carry the project's evidence and demand the same deliverable — degrading to a weaker nudge is acceptable, going silent is not.""" - from scribe.mcp.tools.systems import untagged_systems_hint + from grimoire.mcp.tools.systems import untagged_systems_hint recent = [fake_note(title="Fix scrape retry backoff")] - with patch("scribe.mcp.tools.systems.systems_svc") as svc, \ - patch("scribe.mcp.tools.systems.notes_svc") as notes: + with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \ + patch("grimoire.mcp.tools.systems.notes_svc") as notes: svc.list_systems = AsyncMock(return_value=[]) notes.list_notes = AsyncMock(return_value=(recent, 282)) svc.standard_systems = AsyncMock(side_effect=RuntimeError("db down")) @@ -233,12 +233,12 @@ async def test_bootstrap_ask_still_asks_when_the_catalog_is_unreachable(): @pytest.mark.asyncio async def test_bootstrap_ask_stays_quiet_below_threshold_and_fails_open(): - from scribe.mcp.tools import systems as tools - with patch("scribe.mcp.tools.systems.notes_svc") as notes: + from grimoire.mcp.tools import systems as tools + with patch("grimoire.mcp.tools.systems.notes_svc") as notes: notes.list_notes = AsyncMock( return_value=([], tools._BOOTSTRAP_MIN_RECORDS - 1)) assert await tools.bootstrap_systems_ask(1, 5) is None - with patch("scribe.mcp.tools.systems.notes_svc") as notes: + with patch("grimoire.mcp.tools.systems.notes_svc") as notes: # The record count is decoration on a hint on a create — three layers # deep, nothing there may break the call. notes.list_notes = AsyncMock(side_effect=RuntimeError("db down")) @@ -249,10 +249,10 @@ async def test_bootstrap_ask_stays_quiet_below_threshold_and_fails_open(): async def test_populated_vocabulary_never_counts_records(): """The bootstrap query is zero-state-only: once Systems exist the hint must not spend a count query per untagged record.""" - from scribe.mcp.tools.systems import untagged_systems_hint + from grimoire.mcp.tools.systems import untagged_systems_hint sys_a = MagicMock(); sys_a.id = 1; sys_a.name = "workers" - with patch("scribe.mcp.tools.systems.systems_svc") as svc, \ - patch("scribe.mcp.tools.systems.notes_svc") as notes: + with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \ + patch("grimoire.mcp.tools.systems.notes_svc") as notes: svc.list_systems = AsyncMock(return_value=[sys_a]) notes.list_notes = AsyncMock() hint = await untagged_systems_hint(1, 5) @@ -262,7 +262,7 @@ async def test_populated_vocabulary_never_counts_records(): # The name gate's own cases (case/whitespace normalisation, exact-over-overlap, # fail-open) moved to tests/test_services_systems.py with the logic itself — -# services/systems.assess_system_name, so both doors share one answer (#2482). +# services/systems.assess_system_name, so both doors share one answer. # What stays the TOOL's job — rendering a duplicate, applying an exact area, # offering an overlap — is covered at the top of this file. @@ -271,15 +271,15 @@ async def test_populated_vocabulary_never_counts_records(): async def test_create_task_untagged_in_project_carries_the_question(): note = MagicMock(); note.id = 60; note.to_dict.return_value = {"id": 60} sys_a = MagicMock(); sys_a.id = 4; sys_a.name = "plugin-hooks" - with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \ - patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \ - patch("scribe.mcp.tools.systems.systems_svc") as seam_svc: + with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \ + patch("grimoire.mcp.tools.tasks.dedup_svc") as dedup_svc, \ + patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc: notes_svc.create_note = AsyncMock(return_value=note) dedup_svc.find_duplicate_note = AsyncMock(return_value=None) seam_svc.list_record_systems = AsyncMock(return_value=[]) seam_svc.list_systems = AsyncMock(return_value=[sys_a]) - from scribe.mcp.tools.tasks import create_task + from grimoire.mcp.tools.tasks import create_task result = await create_task(title="untagged", project_id=5) assert "plugin-hooks" in result["systems_hint"] @@ -291,16 +291,16 @@ async def test_create_task_tagged_shows_systems_and_projectless_gets_neither(): # mutation leak into the second create's response. note.to_dict.side_effect = lambda: {"id": 61} tagged_sys = MagicMock(); tagged_sys.to_dict.return_value = {"id": 4, "name": "plugin-hooks"} - with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \ - patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \ - patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc, \ - patch("scribe.mcp.tools.systems.systems_svc") as seam_svc: + with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \ + patch("grimoire.mcp.tools.tasks.dedup_svc") as dedup_svc, \ + patch("grimoire.mcp.tools.tasks.systems_svc") as systems_svc, \ + patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc: notes_svc.create_note = AsyncMock(return_value=note) dedup_svc.find_duplicate_note = AsyncMock(return_value=None) systems_svc.set_record_systems = AsyncMock() seam_svc.list_record_systems = AsyncMock(return_value=[tagged_sys]) - from scribe.mcp.tools.tasks import create_task + from grimoire.mcp.tools.tasks import create_task tagged = await create_task(title="tagged", project_id=5, system_ids=[4]) seam_svc.list_record_systems = AsyncMock(return_value=[]) orphan = await create_task(title="orphan", project_id=0) @@ -318,15 +318,15 @@ async def test_get_task_shows_the_records_systems_on_read(): note.deleted_at = None; note.parent_id = None note.to_dict.return_value = {"id": 70, "task_kind": "work"} tagged_sys = MagicMock(); tagged_sys.to_dict.return_value = {"id": 2, "name": "exporter"} - with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \ - patch("scribe.mcp.tools.tasks.access_svc") as access_svc, \ - patch("scribe.mcp.tools.tasks.record_pulled"), \ - patch("scribe.mcp.tools.systems.systems_svc") as seam_svc: + with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \ + patch("grimoire.mcp.tools.tasks.access_svc") as access_svc, \ + patch("grimoire.mcp.tools.tasks.record_pulled"), \ + patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc: notes_svc.get_note_for_user = AsyncMock(return_value=(note, "owner")) access_svc.describe_provenance = AsyncMock(return_value={}) seam_svc.list_record_systems = AsyncMock(return_value=[tagged_sys]) - from scribe.mcp.tools.tasks import get_task + from grimoire.mcp.tools.tasks import get_task result = await get_task(task_id=70) assert result["systems"] == [{"id": 2, "name": "exporter"}] @@ -336,18 +336,18 @@ async def test_add_task_log_on_untagged_project_task_asks_the_question(): """Logging work IS working in some area — the strongest moment to ask.""" log = MagicMock(); log.to_dict.return_value = {"id": 9, "task_id": 70} task = MagicMock(); task.user_id = 1; task.project_id = 5 - with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.tasks.task_logs_svc") as logs_svc, \ - patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \ - patch("scribe.mcp.tools.systems.systems_svc") as seam_svc, \ - patch("scribe.mcp.tools.systems.notes_svc") as seam_notes: + with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \ + patch("grimoire.mcp.tools.tasks.task_logs_svc") as logs_svc, \ + patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \ + patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc, \ + patch("grimoire.mcp.tools.systems.notes_svc") as seam_notes: logs_svc.create_log = AsyncMock(return_value=log) notes_svc.get_note_for_user = AsyncMock(return_value=(task, "owner")) seam_svc.list_record_systems = AsyncMock(return_value=[]) seam_svc.list_systems = AsyncMock(return_value=[]) - # Young project: below the #2683 bootstrap threshold, so the mild + # Young project: below the bootstrap threshold, so the mild # question is the expected form here. seam_notes.list_notes = AsyncMock(return_value=([], 2)) - from scribe.mcp.tools.tasks import add_task_log + from grimoire.mcp.tools.tasks import add_task_log result = await add_task_log(task_id=70, content="progress") assert "no Systems yet" in result["systems_hint"] diff --git a/tests/test_mcp_tool_tags.py b/tests/test_mcp_tool_tags.py index dcb2231b..d2acb7bc 100644 --- a/tests/test_mcp_tool_tags.py +++ b/tests/test_mcp_tool_tags.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.tools.tags import list_tags, _aggregate_tag_counts +from grimoire.mcp.tools.tags import list_tags, _aggregate_tag_counts from tests.helpers import make_mock_session @@ -35,7 +35,7 @@ async def test_fable_list_tags_returns_sorted_by_count_desc(): mock_session = make_mock_session() mock_session.execute = AsyncMock(return_value=mock_result) mock_ctx = MagicMock(return_value=mock_session) - with patch("scribe.mcp.tools.tags.async_session", mock_ctx): + with patch("grimoire.mcp.tools.tags.async_session", mock_ctx): out = await list_tags() assert out["tags"][0] == {"tag": "a", "count": 3} assert out["tags"][1] == {"tag": "b", "count": 1} @@ -49,7 +49,7 @@ async def test_fable_list_tags_clamps_limit(): mock_session = make_mock_session() mock_session.execute = AsyncMock(return_value=mock_result) mock_ctx = MagicMock(return_value=mock_session) - with patch("scribe.mcp.tools.tags.async_session", mock_ctx): + with patch("grimoire.mcp.tools.tags.async_session", mock_ctx): # No exception — just exercises the clamping branch out = await list_tags(limit=99999) assert out["total"] == 0 diff --git a/tests/test_mcp_tool_tasks.py b/tests/test_mcp_tool_tasks.py index bcbc8d0a..f1a8e32e 100644 --- a/tests/test_mcp_tool_tasks.py +++ b/tests/test_mcp_tool_tasks.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.tools.tasks import ( +from grimoire.mcp.tools.tasks import ( list_tasks, get_task, create_task, update_task, add_task_log, ) @@ -18,7 +18,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user") async def test_list_tasks_passes_is_task_true_and_repackages(): rows = [fake_task(id=1, milestone_id=None), fake_task(id=2, milestone_id=None)] mock = AsyncMock(return_value=(rows, 2)) - with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.list_notes", mock): out = await list_tasks() assert out["total"] == 2 assert len(out["tasks"]) == 2 @@ -28,7 +28,7 @@ async def test_list_tasks_passes_is_task_true_and_repackages(): @pytest.mark.asyncio async def test_list_tasks_status_filter(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.list_notes", mock): await list_tasks(status="in_progress") assert mock.call_args.kwargs["status"] == "in_progress" @@ -36,7 +36,7 @@ async def test_list_tasks_status_filter(): @pytest.mark.asyncio async def test_list_tasks_empty_status_means_no_filter(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.list_notes", mock): await list_tasks(status="") assert mock.call_args.kwargs["status"] is None @@ -45,7 +45,7 @@ async def test_list_tasks_empty_status_means_no_filter(): async def test_get_task_with_no_parent_returns_null_parent_title(): fake = fake_task(id=5, title="solo", parent_id=None) with patch( - "scribe.mcp.tools.tasks.notes_svc.get_note_for_user", + "grimoire.mcp.tools.tasks.notes_svc.get_note_for_user", AsyncMock(return_value=(fake, "owner")), ): out = await get_task(task_id=5) @@ -61,7 +61,7 @@ async def test_get_task_enriches_with_parent_title(): parent = fake_task(id=5, title="parent of 10", parent_id=None) # fetched twice: once for the child, once for the parent mock_get = AsyncMock(side_effect=[(child, "owner"), (parent, "owner")]) - with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get): + with patch("grimoire.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get): out = await get_task(task_id=10) assert out["parent_title"] == "parent of 10" assert mock_get.await_count == 2 @@ -72,7 +72,7 @@ async def test_get_task_parent_missing_returns_null(): """If parent_id is set but the parent is gone (orphaned), parent_title is None.""" child = fake_task(id=10, parent_id=5) mock_get = AsyncMock(side_effect=[(child, "owner"), None]) - with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get): + with patch("grimoire.mcp.tools.tasks.notes_svc.get_note_for_user", mock_get): out = await get_task(task_id=10) assert out["parent_title"] is None @@ -83,10 +83,10 @@ async def test_get_task_opens_a_shared_task_and_says_whose_it_is(): answer "not found" for the same id — and must say it isn't the caller's.""" theirs = fake_task(id=5, title="Their task", user_id=9) with patch( - "scribe.mcp.tools.tasks.notes_svc.get_note_for_user", + "grimoire.mcp.tools.tasks.notes_svc.get_note_for_user", AsyncMock(return_value=(theirs, "viewer")), ), patch( - "scribe.services.access.describe_provenance", + "grimoire.services.access.describe_provenance", AsyncMock(return_value={"shared": True, "owner": "alex", "permission": "viewer"}), ): @@ -98,7 +98,7 @@ async def test_get_task_opens_a_shared_task_and_says_whose_it_is(): @pytest.mark.asyncio async def test_get_task_raises_when_not_found(): with patch( - "scribe.mcp.tools.tasks.notes_svc.get_note_for_user", + "grimoire.mcp.tools.tasks.notes_svc.get_note_for_user", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="task 999 not found"): @@ -109,7 +109,7 @@ async def test_get_task_raises_when_not_found(): async def test_create_task_passes_status(): fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.create_note", mock): await create_task(title="do x", status="todo") assert mock.call_args.kwargs["status"] == "todo" @@ -117,12 +117,12 @@ async def test_create_task_passes_status(): @pytest.mark.asyncio async def test_create_task_blocked_by_duplicate_gate(): """A near-duplicate blocks creation and returns the existing id (no insert).""" - from scribe.services.dedup import DuplicateMatch + from grimoire.services.dedup import DuplicateMatch dup = DuplicateMatch(id=42, title="Set up CI", similarity=1.0, reason="title") create_mock = AsyncMock() - with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", + with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=dup)), \ - patch("scribe.mcp.tools.tasks.notes_svc.create_note", create_mock): + patch("grimoire.mcp.tools.tasks.notes_svc.create_note", create_mock): out = await create_task(title="set up ci") assert out["duplicate"] is True assert out["existing_id"] == 42 @@ -133,8 +133,8 @@ async def test_create_task_blocked_by_duplicate_gate(): async def test_create_task_force_bypasses_duplicate_gate(): """force=true skips the gate entirely and creates.""" find_mock = AsyncMock() - with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", find_mock), \ - patch("scribe.mcp.tools.tasks.notes_svc.create_note", + with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", find_mock), \ + patch("grimoire.mcp.tools.tasks.notes_svc.create_note", AsyncMock(return_value=fake_task(id=9))): out = await create_task(title="dup", force=True) assert out["id"] == 9 @@ -145,7 +145,7 @@ async def test_create_task_force_bypasses_duplicate_gate(): async def test_create_task_rejects_retired_plan_kind(): """kind=plan is hard-retired — plans are milestones (start_planning).""" mock = AsyncMock() - with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.create_note", mock): with pytest.raises(ValueError, match="kind=plan is retired"): await create_task(title="x", kind="plan") assert not mock.called # never reached the create @@ -155,7 +155,7 @@ async def test_create_task_rejects_retired_plan_kind(): async def test_create_task_priority_empty_becomes_none(): fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.create_note", mock): await create_task(title="x", priority="") assert mock.call_args.kwargs["priority"] is None @@ -164,7 +164,7 @@ async def test_create_task_priority_empty_becomes_none(): async def test_create_task_zero_id_sentinels_become_none(): fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.create_note", mock): await create_task(title="x", project_id=0, milestone_id=0, parent_id=0) assert mock.call_args.kwargs["project_id"] is None assert mock.call_args.kwargs["milestone_id"] is None @@ -175,7 +175,7 @@ async def test_create_task_zero_id_sentinels_become_none(): async def test_update_task_only_sends_non_default_fields(): fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, status="done") args, kwargs = mock.call_args assert args == (7, 1) @@ -187,7 +187,7 @@ async def test_update_task_empty_priority_is_omitted(): """Priority="" is "leave unchanged" — must not reach service as empty string.""" fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, status="done", priority="") assert "priority" not in mock.call_args.kwargs @@ -195,7 +195,7 @@ async def test_update_task_empty_priority_is_omitted(): @pytest.mark.asyncio async def test_update_task_raises_when_not_found(): with patch( - "scribe.mcp.tools.tasks.notes_svc.update_note", + "grimoire.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=None), ): with pytest.raises(ValueError, match="task 999 not found"): @@ -207,7 +207,7 @@ async def test_update_task_milestone_zero_is_omitted(): """milestone_id=0 is 'leave unchanged' — must not reach the service.""" fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, milestone_id=0) assert "milestone_id" not in mock.call_args.kwargs @@ -216,7 +216,7 @@ async def test_update_task_milestone_zero_is_omitted(): async def test_update_task_milestone_positive_is_set(): fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, milestone_id=42) assert mock.call_args.kwargs["milestone_id"] == 42 @@ -226,7 +226,7 @@ async def test_update_task_milestone_negative_one_clears(): """milestone_id=-1 clears the milestone (sets the column NULL).""" fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, milestone_id=-1) assert mock.call_args.kwargs["milestone_id"] is None @@ -236,7 +236,7 @@ async def test_update_task_clearing_project_also_clears_milestone(): """project_id=-1 clears the project and, with it, the milestone.""" fake = fake_task() mock = AsyncMock(return_value=fake) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): await update_task(task_id=1, project_id=-1) assert mock.call_args.kwargs["project_id"] is None assert mock.call_args.kwargs["milestone_id"] is None @@ -252,7 +252,7 @@ async def test_add_task_log_returns_log_dict(): # No to_dict on TaskLog model (per task_logs.py inspection) — fall through to manual dict del log.to_dict with patch( - "scribe.mcp.tools.tasks.task_logs_svc.create_log", + "grimoire.mcp.tools.tasks.task_logs_svc.create_log", AsyncMock(return_value=log), ): out = await add_task_log(task_id=1, content="checkpoint") @@ -266,7 +266,7 @@ async def test_add_task_log_returns_log_dict(): async def test_add_task_log_propagates_value_error_when_task_missing(): """create_log raises ValueError if the task doesn't exist; we let it through.""" with patch( - "scribe.mcp.tools.tasks.task_logs_svc.create_log", + "grimoire.mcp.tools.tasks.task_logs_svc.create_log", AsyncMock(side_effect=ValueError("Task 999 not found")), ): with pytest.raises(ValueError): diff --git a/tests/test_mcp_tool_tasks_kind.py b/tests/test_mcp_tool_tasks_kind.py index 06c735ef..3ffa04af 100644 --- a/tests/test_mcp_tool_tasks_kind.py +++ b/tests/test_mcp_tool_tasks_kind.py @@ -11,8 +11,8 @@ pytestmark = pytest.mark.usefixtures("_bind_user") async def test_create_task_passes_kind(): # kind=plan is retired (plans are milestones); 'issue' exercises passthrough. mock = AsyncMock(return_value=fake_note(task_kind="issue")) - with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): - from scribe.mcp.tools.tasks import create_task + with patch("grimoire.mcp.tools.tasks.notes_svc.create_note", mock): + from grimoire.mcp.tools.tasks import create_task await create_task(title="P", kind="issue") assert mock.call_args.kwargs["task_kind"] == "issue" @@ -20,8 +20,8 @@ async def test_create_task_passes_kind(): @pytest.mark.asyncio async def test_list_tasks_passes_kind_filter(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", mock): - from scribe.mcp.tools.tasks import list_tasks + with patch("grimoire.mcp.tools.tasks.notes_svc.list_notes", mock): + from grimoire.mcp.tools.tasks import list_tasks await list_tasks(kind="plan") assert mock.call_args.kwargs["task_kind"] == "plan" @@ -29,38 +29,38 @@ async def test_list_tasks_passes_kind_filter(): @pytest.mark.asyncio async def test_list_tasks_kind_empty_means_no_filter(): mock = AsyncMock(return_value=([], 0)) - with patch("scribe.mcp.tools.tasks.notes_svc.list_notes", mock): - from scribe.mcp.tools.tasks import list_tasks + with patch("grimoire.mcp.tools.tasks.notes_svc.list_notes", mock): + from grimoire.mcp.tools.tasks import list_tasks await list_tasks() assert mock.call_args.kwargs["task_kind"] is None @pytest.mark.asyncio async def test_create_task_passes_spike(): - """The kind a failed rule-check asks for (milestone 312). + """The kind a failed rule-check asks for. Time-boxed, and its output is knowledge rather than a change — filing one as `work` makes a finished investigation look like an abandoned change. """ mock = AsyncMock(return_value=fake_note(task_kind="spike")) - with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock): - from scribe.mcp.tools.tasks import create_task + with patch("grimoire.mcp.tools.tasks.notes_svc.create_note", mock): + from grimoire.mcp.tools.tasks import create_task await create_task(title="Can the runner be given a bash shell?", kind="spike") assert mock.call_args.kwargs["task_kind"] == "spike" @pytest.mark.asyncio async def test_update_task_can_re_file_a_kind(): - """A task's kind must be CORRECTABLE, not write-once (#3129). + """A task's kind must be CORRECTABLE, not write-once. What a piece of work turns out to be is often clear only once it is under - way. Before this, `update_task` had no `kind` parameter at all and the - REST route dropped the field the editor was already sending — the save - reported success and reverted on reload. + way. Without a `kind` parameter on `update_task`, and with a REST route that + drops the field the editor sends, the save reports success and reverts on + reload. """ mock = AsyncMock(return_value=fake_note(task_kind="spike")) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): - from scribe.mcp.tools.tasks import update_task + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): + from grimoire.mcp.tools.tasks import update_task await update_task(task_id=1, kind="spike") assert mock.call_args.kwargs["task_kind"] == "spike" @@ -69,8 +69,8 @@ async def test_update_task_can_re_file_a_kind(): async def test_update_task_leaves_kind_alone_when_not_given(): """"" means leave unchanged, as it does for every other field here.""" mock = AsyncMock(return_value=fake_note()) - with patch("scribe.mcp.tools.tasks.notes_svc.update_note", mock): - from scribe.mcp.tools.tasks import update_task + with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", mock): + from grimoire.mcp.tools.tasks import update_task await update_task(task_id=1, title="renamed") assert "task_kind" not in mock.call_args.kwargs @@ -84,7 +84,7 @@ async def test_minting_a_plan_is_refused_at_both_doors(): hands out a new one. The error names start_planning rather than just refusing, because a caller reaching for kind='plan' wants a plan. """ - from scribe.services.notes import minted_kind + from grimoire.services.notes import minted_kind with pytest.raises(ValueError, match="start_planning"): minted_kind("plan") @@ -93,7 +93,7 @@ async def test_minting_a_plan_is_refused_at_both_doors(): @pytest.mark.asyncio async def test_an_unknown_kind_raises_rather_than_defaulting(): """A silently-corrected kind is the defect this whole fix exists to end.""" - from scribe.services.notes import minted_kind + from grimoire.services.notes import minted_kind with pytest.raises(ValueError, match="kind must be one of"): minted_kind("investigation") diff --git a/tests/test_mcp_tool_trash.py b/tests/test_mcp_tool_trash.py index 26fdc4c6..d375452d 100644 --- a/tests/test_mcp_tool_trash.py +++ b/tests/test_mcp_tool_trash.py @@ -10,18 +10,18 @@ pytestmark = pytest.mark.usefixtures("_bind_user") @pytest.mark.asyncio async def test_list_trash_wraps_batches(): - with patch("scribe.mcp.tools.trash.trash_svc.list_trash", + with patch("grimoire.mcp.tools.trash.trash_svc.list_trash", AsyncMock(return_value=[{"batch_id": "b1", "count": 2}])): - from scribe.mcp.tools.trash import list_trash + from grimoire.mcp.tools.trash import list_trash out = await list_trash() assert out["batches"][0]["batch_id"] == "b1" @pytest.mark.asyncio async def test_restore_delegates(): - with patch("scribe.mcp.tools.trash.trash_svc.restore", + with patch("grimoire.mcp.tools.trash.trash_svc.restore", AsyncMock(return_value=3)) as mock: - from scribe.mcp.tools.trash import restore + from grimoire.mcp.tools.trash import restore out = await restore("b1") assert out == {"restored": 3, "batch_id": "b1"} assert mock.call_args.args == (7, "b1") @@ -29,8 +29,8 @@ async def test_restore_delegates(): @pytest.mark.asyncio async def test_purge_trash_requires_confirmed(): - with patch("scribe.mcp.tools.trash.trash_svc.purge", AsyncMock()) as mock: - from scribe.mcp.tools.trash import purge_trash + with patch("grimoire.mcp.tools.trash.trash_svc.purge", AsyncMock()) as mock: + from grimoire.mcp.tools.trash import purge_trash out = await purge_trash("b1", confirmed=False) assert out.get("confirmed_required") is True assert not mock.called @@ -38,16 +38,16 @@ async def test_purge_trash_requires_confirmed(): @pytest.mark.asyncio async def test_purge_trash_when_confirmed(): - with patch("scribe.mcp.tools.trash.trash_svc.purge", + with patch("grimoire.mcp.tools.trash.trash_svc.purge", AsyncMock(return_value=5)) as mock: - from scribe.mcp.tools.trash import purge_trash + from grimoire.mcp.tools.trash import purge_trash out = await purge_trash("b1", confirmed=True) assert out == {"purged": 5, "batch_id": "b1"} assert mock.called def test_register_attaches_three_tools(): - from scribe.mcp.tools.trash import register + from grimoire.mcp.tools.trash import register mcp = FakeMCP() register(mcp) diff --git a/tests/test_measure_duplication.py b/tests/test_measure_duplication.py index e392ff34..32a4cf42 100644 --- a/tests/test_measure_duplication.py +++ b/tests/test_measure_duplication.py @@ -1,4 +1,4 @@ -"""The DRY close-out measure (#4745). +"""The DRY close-out measure. Stdlib-only and kept in scripts/ so any project can run a scratch copy of it, so these tests import it by path rather than as a package. diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py index fdf8bffc..119fecfe 100644 --- a/tests/test_milestone_summary_brief.py +++ b/tests/test_milestone_summary_brief.py @@ -1,9 +1,9 @@ -"""The enter_project handshake stays a small primer (#4045). +"""The enter_project handshake stays a small primer. -enter_project once carried every milestone's full plan body, the whole project -record, full rule text and ~9k of design guidance. On a project with 39 -milestones that came to ~222k characters, past what an MCP client accepts as -a tool result, so the session handshake arrived as a file to page through. +Every milestone's full plan body, the whole project record, full rule text and +the design guidance add up, on a project with a few dozen milestones, to more +than an MCP client accepts as a tool result — and the session handshake +arrives as a file to page through. """ import contextlib import json @@ -11,10 +11,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.mcp.tools.milestones import list_milestones -from scribe.mcp.tools.projects import enter_project, get_project -from scribe.services.milestones import brief_milestone_summary -from scribe.services.milestones import unplanned_milestones as brief_unplanned +from grimoire.mcp.tools.milestones import list_milestones +from grimoire.mcp.tools.projects import enter_project, get_project +from grimoire.services.milestones import brief_milestone_summary +from grimoire.services.milestones import unplanned_milestones as brief_unplanned from tests.helpers import fake_project @@ -31,7 +31,7 @@ def _no_platforms(): case — a project with none yet. The populated shape is asserted in its own test. """ - with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms", + with patch("grimoire.mcp.tools.projects.platforms_svc.project_platforms", AsyncMock(return_value=[])): yield @@ -66,7 +66,7 @@ def test_brief_rows_leave_out_the_plan_and_what_the_caller_already_knows(): "id": 1, "title": "M1", "description": "what M1 is for", "status": "active", "order_index": 1, "total": 4, "completed": 2, "pct": 50.0, "status_counts": {"todo": 2, "in_progress": 0, "done": 2, "cancelled": 0}, - # Trimmed of the plan body, but NOT of which step is next (#4154): the + # Trimmed of the plan body, but NOT of which step is next: the # listing is where "2 of 4 done" gets read, and that number is exactly # what invites a reader to name the open step from memory. "next_step": {"id": 901, "title": "M1 step 3", "status": "todo"}, @@ -101,25 +101,25 @@ def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, sy design=None): applicable = rules or {"rules": [], "project_rules": [], "truncated": False} return [ - patch("scribe.mcp.tools.projects.projects_svc.get_project", + patch("grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), - patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + patch("grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), - patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + patch("grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestones)), - patch("scribe.mcp.tools.projects.notes_svc.list_notes", + patch("grimoire.mcp.tools.projects.notes_svc.list_notes", AsyncMock(return_value=(tasks, len(tasks)))), - patch("scribe.mcp.tools.projects.systems_svc.list_systems", + patch("grimoire.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=systems or [])), - patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", + patch("grimoire.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), - patch("scribe.mcp.tools.projects.design_systems_svc.design_context", + patch("grimoire.mcp.tools.projects.design_systems_svc.design_context", AsyncMock(return_value=design)), - patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage", + patch("grimoire.mcp.tools.projects.coverage_svc.cached_coverage", AsyncMock(return_value=None)), - patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout", + patch("grimoire.mcp.tools.projects.family_adoption_svc.family_readout", AsyncMock(return_value=None)), - patch("scribe.mcp.tools.projects.spawn"), + patch("grimoire.mcp.tools.projects.spawn"), ] @@ -134,7 +134,7 @@ async def _enter(*stubs): async def test_enter_project_stays_small_however_large_the_project(): """The ceiling is the point: a long-lived project's handshake must not grow with its history. 200 milestones with 5k-character plans, 60 project - rules and 40 Systems would be well over 1M characters in the old shape.""" + rules and 40 Systems would be well over 1M characters carried whole.""" project = fake_project(id=5, design_system_id=9, goal=GOAL, description="Background. " * 300) rules = { @@ -187,7 +187,7 @@ async def test_open_tasks_name_their_milestone_even_when_it_is_not_listed(): @pytest.mark.asyncio async def test_omitted_key_is_absent_when_nothing_was_left_out(): - """Attached only when it applies (#2483).""" + """Attached only when it applies.""" out, _ = await _enter(*_enter_stubs(fake_project(id=5), _history(3), [])) assert len(out["milestone_summary"]) == 3 assert "milestone_summary_omitted" not in out @@ -195,11 +195,11 @@ async def test_omitted_key_is_absent_when_nothing_was_left_out(): @pytest.mark.asyncio async def test_get_project_lists_every_milestone_without_plans(): - with patch("scribe.mcp.tools.projects.projects_svc.get_project", + with patch("grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=fake_project(id=5))), \ - patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + patch("grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=_history(10))), \ - patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + patch("grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value={"rules": [], "truncated": False})): out = await get_project(project_id=5) assert len(out["milestone_summary"]) == 10 @@ -209,7 +209,7 @@ async def test_get_project_lists_every_milestone_without_plans(): @pytest.mark.asyncio async def test_list_milestones_lists_every_milestone_without_plans(): """The call milestone_summary_omitted points to.""" - with patch("scribe.mcp.tools.milestones.milestones_svc.get_project_milestone_summary", + with patch("grimoire.mcp.tools.milestones.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=_history(30))): out = await list_milestones(project_id=5) assert len(out["milestones"]) == 30 @@ -217,10 +217,10 @@ async def test_list_milestones_lists_every_milestone_without_plans(): def test_a_planning_read_lists_rules_without_restating_them(): - """#4081: a project's listing is every global rule tagged to its areas, so - the full rule_brief of each put start_planning at 92k characters. Planning + """A project's listing is every global rule tagged to its areas, so the + full rule_brief of each would make a planning read enormous. Planning reads name the rules; get_rule reads one.""" - from scribe.services.rulebooks import rules_payload + from grimoire.services.rulebooks import rules_payload applicable = { "rules": [{"id": i, "title": f"r{i}", "statement": PLAN, "when_to_apply": PLAN, @@ -230,7 +230,7 @@ def test_a_planning_read_lists_rules_without_restating_them(): for i in range(30)], "truncated": True, } - with patch("scribe.services.rulebooks.record_rule_surfaced") as surfaced: + with patch("grimoire.services.rulebooks.record_rule_surfaced") as surfaced: out = rules_payload(applicable, user_id=7, source="start_planning") assert out["applicable_rules"][0] == {"id": 0, "title": "r0", "topic_title": "git"} @@ -241,7 +241,7 @@ def test_a_planning_read_lists_rules_without_restating_them(): assert len(json.dumps(out)) < 6_000, len(json.dumps(out)) -# ── Milestones with no steps are open work (milestone 415) ───────────────── +# ── Milestones with no steps are open work ───────────────────────────────── def _planless(mid: int, status: str = "active") -> dict: diff --git a/tests/test_moment_actions.py b/tests/test_moment_actions.py index 60dd7db4..cab3ceb2 100644 --- a/tests/test_moment_actions.py +++ b/tests/test_moment_actions.py @@ -1,4 +1,4 @@ -"""Which actions reach which moment (milestone 458 step 2) — the pure half. +"""Which actions reach which moment — the pure half. Matching and resolution take no database: the defaults are code and an install's mappings arrive as rows, so every case below hands `resolve` the @@ -10,8 +10,8 @@ from unittest.mock import AsyncMock, patch import pytest -from scribe.services import moment_actions as ma -from scribe.services import moments +from grimoire.services import moment_actions as ma +from grimoire.services import moments def _row(tool, match, moment, effect=ma.ADD): @@ -86,7 +86,7 @@ def test_an_unknown_tool_reaches_nothing(): @pytest.mark.parametrize("name", [ - "mcp__plugin_scribe_scribe__update_task", "mcp__scribe__update_task", + "mcp__plugin_grimoire_grimoire__update_task", "mcp__grimoire__update_task", "update_task", "Update_Task", ]) def test_the_server_prefix_and_case_do_not_matter(name): @@ -115,10 +115,10 @@ def test_loading_a_procedure_reaches_its_own_moment(): def test_a_bundled_skill_also_reaches_the_moment_it_is_for(): """Loading the planning procedure IS planning: a rule mounted on work.plan arrives on the load, not only on the plan it goes on to open.""" - assert _reached("Skill", {"skill": "scribe:writing-plans"}) == [ - "work.plan", "skill.scribe:writing-plans", + assert _reached("Skill", {"skill": "grimoire:writing-plans"}) == [ + "work.plan", "skill.grimoire:writing-plans", ] - assert _reached("Skill", {"skill": "scribe:reporting-back"})[0] == "reply.report" + assert _reached("Skill", {"skill": "grimoire:reporting-back"})[0] == "reply.report" def test_only_the_products_own_skill_reaches_a_bundled_default(): @@ -135,16 +135,16 @@ def test_a_procedure_name_that_is_not_a_moment_reaches_nothing(): # ── a stored process's own moments ─────────────────────────────────────── def test_a_process_declares_moments_its_load_reaches(): - hits = ma.resolve("Skill", {"skill": "scribe-proc-release"}, [], ["work.deliver"]) + hits = ma.resolve("Skill", {"skill": "grimoire-proc-release"}, [], ["work.deliver"]) assert [(h["moment"], h["via"]) for h in hits] == [ - ("work.deliver", ma.PROCESS), ("skill.scribe-proc-release", ma.DEFAULT), + ("work.deliver", ma.PROCESS), ("skill.grimoire-proc-release", ma.DEFAULT), ] - assert hits[0]["match"] == "skill=scribe-proc-release" + assert hits[0]["match"] == "skill=grimoire-proc-release" def test_a_declared_moment_another_action_reaches_is_named_once(): - rows = [_row("Skill", "skill=scribe-proc-release", "work.deliver")] - hits = ma.resolve("Skill", {"skill": "scribe-proc-release"}, rows, ["work.deliver"]) + rows = [_row("Skill", "skill=grimoire-proc-release", "work.deliver")] + hits = ma.resolve("Skill", {"skill": "grimoire-proc-release"}, rows, ["work.deliver"]) assert [h["moment"] for h in hits].count("work.deliver") == 1 @@ -169,22 +169,22 @@ async def test_only_a_process_skill_asks_for_its_process(): asked = AsyncMock(return_value=["work.deliver"]) with patch.object(ma, "list_mappings", AsyncMock(return_value=[])), \ patch.object(ma, "process_moments", asked): - reached = await ma.moments_for(1, "Skill", {"skill": "scribe-proc-release"}) - await ma.moments_for(1, "Skill", {"skill": "scribe:verification"}) + reached = await ma.moments_for(1, "Skill", {"skill": "grimoire-proc-release"}) + await ma.moments_for(1, "Skill", {"skill": "grimoire:verification"}) await ma.moments_for(1, "Bash", {"command": "git push"}) assert "work.deliver" in [h["moment"] for h in reached] - asked.assert_awaited_once_with(1, "scribe-proc-release") + asked.assert_awaited_once_with(1, "grimoire-proc-release") async def _process_moments(manifest, note=None, *, raises=False): - from scribe.services import notes as notes_svc - from scribe.services import plugin_context + from grimoire.services import notes as notes_svc + from grimoire.services import plugin_context built = AsyncMock(side_effect=RuntimeError("down")) if raises else AsyncMock(return_value=manifest) loader = AsyncMock(return_value=(note, None) if note is not None else None) with patch.object(plugin_context, "build_process_manifest", built), \ patch.object(notes_svc, "get_note_for_user", loader): - return await ma.process_moments(1, "scribe-proc-release"), loader + return await ma.process_moments(1, "grimoire-proc-release"), loader async def test_a_process_slug_resolves_through_the_sync_manifest(): diff --git a/tests/test_moment_delivery.py b/tests/test_moment_delivery.py index 981bb1d7..f18a70fc 100644 --- a/tests/test_moment_delivery.py +++ b/tests/test_moment_delivery.py @@ -1,4 +1,4 @@ -"""Delivering mounted rules when their moment happens (milestone 458 step 4). +"""Delivering mounted rules when their moment happens. The database is stubbed: the lookup's scoping is pinned against Postgres in tests/test_integration_rule_moments.py. These pin what is decided above it: @@ -8,7 +8,7 @@ tests/test_integration_rule_moments.py. These pin what is decided above it: fresh ones are recorded, each with its moment; - every door fails open; - the plugin is told which tools can reach anything, and nothing more; -- every Scribe tool the shipped mappings name carries the attach, and the +- every Grimoire tool the shipped mappings name carries the attach, and the hook and the route agree on every name between them. """ from __future__ import annotations @@ -24,17 +24,17 @@ from unittest.mock import AsyncMock, MagicMock, patch from quart import Quart, g -from scribe.services import moment_actions -from scribe.services import moment_delivery as md -from scribe.services import retrieval_pipeline as rp -from scribe.services import rule_usage, rulebooks +from grimoire.services import moment_actions +from grimoire.services import moment_delivery as md +from grimoire.services import retrieval_pipeline as rp +from grimoire.services import rule_usage, rulebooks from tests.helpers import fake_rule, http_sink, need_tools # Bound before conftest's autouse stub replaces the module attribute. _REAL_DELIVER = md.deliver_for_act ROOT = Path(__file__).resolve().parents[1] -HOOK = ROOT / "plugin" / "hooks" / "scribe_moment.sh" +HOOK = ROOT / "plugin" / "hooks" / "grimoire_moment.sh" def _rule(rid, title="Done means delivered and checked"): @@ -186,7 +186,7 @@ async def _reachable(mounted, mappings=()): # The tools the shipped defaults map onto a moment that carries a default reply -# shape (milestone 500): closing work, a structured question, opening a plan. +# shape: closing work, a structured question, opening a plan. SHAPED = ["askuserquestion", "create_milestone", "enterplanmode", "exitplanmode", "skill", "start_planning", "update_milestone", "update_task"] @@ -222,7 +222,7 @@ async def test_an_installs_own_mapping_and_removal_both_count(): async def test_the_route_reads_the_event_and_the_ledger(): - from scribe.routes import plugin as routes + from grimoire.routes import plugin as routes deliver = AsyncMock(return_value=(PUSH, rp.RuleResult( lines=["a line"], rule_ids=[5], shown_rule_ids=[5, 9], @@ -245,7 +245,7 @@ async def test_the_route_reads_the_event_and_the_ledger(): async def test_the_route_answers_an_empty_event_with_nothing(): - from scribe.routes import plugin as routes + from grimoire.routes import plugin as routes app = Quart(__name__) async with app.test_request_context("/api/plugin/moment", method="POST", json={}): @@ -256,7 +256,7 @@ async def test_the_route_answers_an_empty_event_with_nothing(): def test_both_routes_are_on_the_app(): - from scribe.app import create_app + from grimoire.app import create_app rules = {str(r.rule) for r in create_app().url_map.iter_rules()} assert {"/api/plugin/moment", "/api/plugin/moment-tools"} <= rules @@ -269,22 +269,22 @@ def test_the_hook_is_registered_on_every_tool(): manifest = json.loads((ROOT / "plugin" / "hooks" / "hooks.json").read_text()) entries = {m.get("matcher"): [h["command"] for h in m["hooks"]] for m in manifest["hooks"]["PreToolUse"]} - assert any("scribe_moment.sh" in c for c in entries.get("*", [])) + assert any("grimoire_moment.sh" in c for c in entries.get("*", [])) def test_the_hook_and_the_routes_agree_on_every_name(): - """Rule 33 across the shell/Python seam: a renamed arg fails silently.""" + """Parity across the shell/Python seam: a renamed arg fails silently.""" src = HOOK.read_text() assert "/api/plugin/moment-tools" in src and "/api/plugin/moment" in src - assert "exclude_rule_ids" in src and "scribe_held_query" in src - assert "scribe_rules_live" in src and "scribe_rules_append" in src + assert "exclude_rule_ids" in src and "grimoire_held_query" in src + assert "grimoire_rules_live" in src and "grimoire_rules_append" in src # The SHARED ledger, not one of its own. - assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src and ".rules.ids" in src + assert '"${TMPDIR:-/tmp}/grimoire-priorart"' in src and ".rules.ids" in src for field in (".tools", ".rule_ids", ".context", ".shape_keys"): assert f"'{field}'" in src - assert "shapes_seen" in src and "scribe_shapes_append" in src + assert "shapes_seen" in src and "grimoire_shapes_append" in src - from scribe.routes import plugin as routes + from grimoire.routes import plugin as routes route = inspect.getsource(routes.moment) for name in ("exclude_rule_ids", "held_rule_ids", "tool_name", "tool_input", @@ -292,20 +292,20 @@ def test_the_hook_and_the_routes_agree_on_every_name(): assert name in route -def test_the_hook_leaves_scribes_own_tools_to_their_responses(): - assert "mcp__*scribe*__*) exit 0" in HOOK.read_text() +def test_the_hook_leaves_grimoires_own_tools_to_their_responses(): + assert "mcp__*grimoire*__*) exit 0" in HOOK.read_text() def test_the_hook_never_returns_a_permission_decision(): """A mount is a recall aid: it informs the act and never holds it.""" code = [ln for ln in HOOK.read_text().splitlines() if not ln.lstrip().startswith("#")] - assert not any("permissionDecision" in ln or "scribe_json_deny" in ln for ln in code) + assert not any("permissionDecision" in ln or "grimoire_json_deny" in ln for ln in code) def _hook(tmp_path, port, tool="Bash", command="git push origin dev", session="s-moment"): need_tools("bash", "curl", "awk") - env = {"PATH": os.environ["PATH"], "SCRIBE_URL": f"http://127.0.0.1:{port}", - "SCRIBE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)} + env = {"PATH": os.environ["PATH"], "GRIMOIRE_URL": f"http://127.0.0.1:{port}", + "GRIMOIRE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)} out = subprocess.run( ["bash", str(HOOK)], input=json.dumps({"session_id": session, "cwd": str(tmp_path), "tool_name": tool, @@ -341,7 +341,7 @@ def test_a_listed_tool_is_asked_about_and_its_rules_injected(tmp_path): def test_an_unlisted_tool_never_leaves_the_machine(tmp_path): with http_sink(by_path=TOOLS) as (port, seen): assert _hook(tmp_path, port, tool="Read") == "" - assert _hook(tmp_path, port, tool="mcp__plugin_scribe_scribe__update_task") == "" + assert _hook(tmp_path, port, tool="mcp__plugin_grimoire_grimoire__update_task") == "" assert [e["_path"] for e in seen] == ["/api/plugin/moment-tools"] @@ -352,7 +352,7 @@ def test_an_install_with_nothing_mounted_sends_one_request_per_window(tmp_path): assert len(seen) == 1 -# ── the MCP door: every shipped Scribe action carries the attach ──────── +# ── the MCP door: every shipped Grimoire action carries the attach ──────── def _attached_names(path: Path) -> dict[str, set[str]]: @@ -371,16 +371,16 @@ def _attached_names(path: Path) -> dict[str, set[str]]: return out -def test_every_scribe_tool_the_defaults_name_attaches_its_moment_rules(): - tools_dir = ROOT / "src" / "scribe" / "mcp" / "tools" +def test_every_grimoire_tool_the_defaults_name_attaches_its_moment_rules(): + tools_dir = ROOT / "src" / "grimoire" / "mcp" / "tools" defined: dict[str, dict[str, set[str]]] = {} - scribe_tools: set[str] = set() + grimoire_tools: set[str] = set() for path in tools_dir.glob("*.py"): tree = ast.parse(path.read_text()) - scribe_tools |= {n.name for n in tree.body if isinstance(n, ast.AsyncFunctionDef)} + grimoire_tools |= {n.name for n in tree.body if isinstance(n, ast.AsyncFunctionDef)} defined[path.name] = _attached_names(path) - shipped = {a.tool for a in moment_actions.DEFAULT_ACTIONS} & scribe_tools + shipped = {a.tool for a in moment_actions.DEFAULT_ACTIONS} & grimoire_tools assert {"update_task", "create_rule", "update_milestone"} <= shipped for tool in sorted(shipped): named = set().union(*(per_file.get(tool, set()) for per_file in defined.values())) @@ -391,9 +391,9 @@ def test_every_scribe_tool_the_defaults_name_attaches_its_moment_rules(): ) -# ── reply shapes ride the moments (milestone 500 step 3) ──────────────── +# ── reply shapes ride the moments ─────────────────────────────────────── -from scribe.services import reply_shapes # noqa: E402 +from grimoire.services import reply_shapes # noqa: E402 FINISH = [{"moment": "work.finish", "tool": "update_task", "match": "status=done", "via": "default"}] ASK = [{"moment": "reply.ask", "tool": "AskUserQuestion", "match": "", "via": "default"}] @@ -463,7 +463,7 @@ async def test_an_unshaped_act_through_a_tool_carries_no_shape(): async def test_the_route_puts_the_shape_ahead_of_the_rules_and_returns_its_key(): - from scribe.routes import plugin as routes + from grimoire.routes import plugin as routes deliver = AsyncMock(return_value=(ASK, rp.RuleResult(lines=["a line"], rule_ids=[5]))) app = Quart(__name__) diff --git a/tests/test_moments.py b/tests/test_moments.py index 92466e27..0e4c88ab 100644 --- a/tests/test_moments.py +++ b/tests/test_moments.py @@ -1,4 +1,4 @@ -"""The moment catalog — the vocabulary rules mount on (milestone 458 step 1). +"""The moment catalog — the vocabulary rules mount on. What this pins is what a mount depends on: every name is well-formed and resolvable, an unknown name is refused rather than stored, the catalog speaks @@ -9,14 +9,14 @@ import re import pytest -from scribe.services import moments +from grimoire.services import moments from tests.helpers import FakeMCP, dev_only_hits _NAME = re.compile(r"^[a-z]+\.[a-z]+$") def test_the_catalog_is_not_empty(): - """The sweeps below are vacuous over an empty catalog (rule 167).""" + """The sweeps below are vacuous over an empty catalog.""" assert len(moments.MOMENTS) >= 10 @@ -60,8 +60,8 @@ def test_every_catalog_moment_is_mountable(name): @pytest.mark.parametrize("raw,clean", [ (" Work.Finish ", "work.finish"), ("skill.verification", "skill.verification"), - ("skill.scribe:writing-plans", "skill.scribe:writing-plans"), - ("skill.scribe-proc-release_notes", "skill.scribe-proc-release_notes"), + ("skill.grimoire:writing-plans", "skill.grimoire:writing-plans"), + ("skill.grimoire-proc-release_notes", "skill.grimoire-proc-release_notes"), ]) def test_a_name_is_normalised_before_it_is_judged(raw, clean): assert moments.require_moment(raw) == clean @@ -88,7 +88,7 @@ def test_the_catalog_payload_carries_every_moment_and_the_family(): async def test_the_tool_returns_the_catalog_with_this_installs_actions(): from unittest.mock import AsyncMock, patch - from scribe.mcp.tools import moments as tool + from grimoire.mcp.tools import moments as tool by_moment = {"actions": {"work.change": []}, "removed_defaults": []} with patch.object(tool, "current_user_id", lambda: 7), \ @@ -102,8 +102,8 @@ async def test_the_tool_returns_the_catalog_with_this_installs_actions(): def test_the_tools_are_registered_and_classified(): - from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS - from scribe.mcp.tools import moments as tool + from grimoire.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS + from grimoire.mcp.tools import moments as tool mcp = FakeMCP() tool.register(mcp) @@ -120,12 +120,12 @@ def test_the_tools_are_registered_and_classified(): def test_both_doors_read_one_catalog(): - """Rule 33 parity: the tool and the routes call the same services, so the + """Door parity: the tool and the routes call the same services, so the session and the Settings view cannot name different moments or disagree about what a mapping does.""" - from scribe.mcp.tools import moments as tool - from scribe.routes import retrieval as routes - from scribe.services import moment_actions + from grimoire.mcp.tools import moments as tool + from grimoire.routes import retrieval as routes + from grimoire.services import moment_actions assert tool.moments_svc is moments assert routes.moments_svc is moments diff --git a/tests/test_named_records.py b/tests/test_named_records.py index cade3b5f..71fb9d2e 100644 --- a/tests/test_named_records.py +++ b/tests/test_named_records.py @@ -1,9 +1,9 @@ -"""A record the operator names by number is looked up, not left to ranking (#4796). +"""A record the operator names by number is looked up, not left to ranking. -"yes go ahead with 4448" says exactly which record is meant, and the prompt -menu could not use it: a number means nothing to an embedding, so the menu -filled with whatever resembled the words around it. The parser cases below are -drawn from real operator prompts, and the half that must NOT be read as a +"yes go ahead with 4448" says exactly which record is meant, and a menu +built by ranking cannot use it: a number means nothing to an embedding, so the +menu fills with whatever resembles the words around it. The parser cases below +are drawn from real operator prompts, and the half that must NOT be read as a reference matters as much as the half that must: every misread number is a stranger's record dropped in front of the reader. """ @@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.services.record_refs import NAMED_LIMIT, named_record_ids +from grimoire.services.record_refs import NAMED_LIMIT, named_record_ids from tests.helpers import fake_note @@ -79,7 +79,7 @@ async def _hint(prompt, *, hits=(), records=None, seen=(), context=""): `records` is what the ACL lookup can see, by id; anything else is inaccessible. """ - from scribe.services import plugin_context as pc + from grimoire.services import plugin_context as pc records = records or {} calls: list[int] = [] @@ -181,8 +181,8 @@ async def test_an_id_the_reader_cannot_open_is_dropped(): @pytest.mark.asyncio async def test_a_number_in_the_assistants_reply_is_not_named(): - """The query is enriched with the reply before a short prompt is searched - (#4364); only the operator's own words can name a record.""" + """The query is enriched with the reply before a short prompt is searched; + only the operator's own words can name a record.""" rec = _task(4448, "the named one") out, _, _ = await _hint( "sounds good", records={4448: rec}, diff --git a/tests/test_note_pipeline.py b/tests/test_note_pipeline.py index 3b826cb2..705390c3 100644 --- a/tests/test_note_pipeline.py +++ b/tests/test_note_pipeline.py @@ -1,9 +1,9 @@ -"""The notes corpus on the one retrieval pipeline (milestone 456 step 4). +"""The notes corpus on the one retrieval pipeline. `run_note_arm` is driven directly here, with the search and both recorders stubbed through `NoteIO`: the stage order, the invariants the rule arms -already pin (#3497's call row before any return, #3752's fresh-only cut, -#4101's ledger-renders-not-removes), the write path's withheld menu (#3739), +already pin (the call row before any return, the fresh-only cut, the ledger +renders rather than removes), the write path's withheld menu, and the two reserved slots in their load-bearing order. The builders that compose these arms keep their own tests; these pin the stages they share. """ @@ -15,10 +15,10 @@ from unittest.mock import MagicMock import pytest -from scribe.services import retrieval_pipeline as rp -from scribe.services.lessons import LESSON_NOTE_TYPE -from scribe.services.retrieval_registry import FAN_OUT_SITES, POINTS -from scribe.services.retrieval_surfaces import SURFACES +from grimoire.services import retrieval_pipeline as rp +from grimoire.services.lessons import LESSON_NOTE_TYPE +from grimoire.services.retrieval_registry import FAN_OUT_SITES, POINTS +from grimoire.services.retrieval_surfaces import SURFACES from tests.helpers import fake_note @@ -53,7 +53,7 @@ async def _run(arm, route, *, budget=3, **moment): return result, io, calls -# ── the call row (#3497, #3752) ─────────────────────────────────────────── +# ── the call row ────────────────────────────────────────────────────────── @pytest.mark.asyncio @@ -75,7 +75,7 @@ async def test_repeats_and_named_records_are_counted_not_reported(): (row,) = _rows(io.record_retrieval, "auto_inject") assert [int(n.id) for _s, n in row["results"]] == [22] assert row["suppressed"] == 2 - # The repeat stays on the menu (#4101); the named record does not — it is + # The repeat stays on the menu; the named record does not — it is # shown by its own block. assert [int(n.id) for _s, n in result.menu] == [11, 22] (surfaced,) = _rows(io.record_surfaced, "auto_inject") @@ -86,7 +86,7 @@ def test_every_notes_stage_logs_before_it_can_return(): """Structural, like the rule arms' guard: no `return` in a notes stage precedes its call row. The via-lesson arm's one exception is the return before any search runs — nothing was asked, so there is nothing to log.""" - tree = ast.parse(Path("src/scribe/services/retrieval_pipeline.py").read_text()) + tree = ast.parse(Path("src/grimoire/services/retrieval_pipeline.py").read_text()) fns = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.AsyncFunctionDef)} for name in ("run_note_arm", "_reserve_note_slot", "run_via_lesson_arm"): fn = fns[name] @@ -105,7 +105,7 @@ def test_every_notes_stage_logs_before_it_can_return(): @pytest.mark.asyncio async def test_the_band_narrows_the_menu_after_the_row_is_written(): - """Notes log BEFORE the band (#2085) — the row is the candidate set a + """Notes log BEFORE the band — the row is the candidate set a floor is tuned against, the surfacing rows are what the reader saw.""" hits = [_note(1, 0.90), _note(2, 0.70)] result, io, _ = await _run(rp.WRITE_PATH, lambda kw: hits) @@ -141,7 +141,7 @@ async def test_a_recorder_that_raises_costs_only_its_row(): assert [int(n.id) for _s, n in result.menu] == [1] -# ── the write path's withheld menu (#3739) ──────────────────────────────── +# ── the write path's withheld menu ──────────────────────────────────────── @pytest.mark.asyncio @@ -223,7 +223,7 @@ async def test_a_slot_stands_down_when_its_kind_won_on_score(): @pytest.mark.asyncio async def test_a_slot_spends_itself_on_a_repeat_without_booking_it_again(): """A record shown in an EARLIER call is exactly what a slot may spend - itself on (#4101) — it is rendered, but it is not a new surfacing.""" + itself on — it is rendered, but it is not a new surfacing.""" main = [_note(1, 0.70)] lesson = [_note(42, 0.58, note_type=LESSON_NOTE_TYPE)] result, io, _ = await _run( @@ -257,14 +257,14 @@ def test_every_notes_source_is_registered_and_every_arm_is_tunable(): def test_the_pipelines_fan_out_sites_declare_every_notes_source(): logged = FAN_OUT_SITES[ - "scribe/services/retrieval_pipeline.py::record_retrieval(source=source)"] + "grimoire/services/retrieval_pipeline.py::record_retrieval(source=source)"] surfaced = FAN_OUT_SITES[ - "scribe/services/retrieval_pipeline.py::record_surfaced(source=source)"] + "grimoire/services/retrieval_pipeline.py::record_surfaced(source=source)"] assert set(rp.NOTE_SOURCES) <= set(logged) assert set(rp.NOTE_SURFACED_SOURCES) == set(surfaced) # Not the arm's: the slot whose line it is, and the lookups' own site. assert "write_path_semantic" not in FAN_OUT_SITES[ - "scribe/services/plugin_context.py::record_surfaced(source=arm)"] + "grimoire/services/plugin_context.py::record_surfaced(source=arm)"] def test_the_filters_say_which_kinds_and_whose_records(): diff --git a/tests/test_note_usage.py b/tests/test_note_usage.py index 4bcc5176..ec3e4d52 100644 --- a/tests/test_note_usage.py +++ b/tests/test_note_usage.py @@ -1,9 +1,9 @@ -"""Tests for the usage signal (#2085) — was a surfaced record ever pulled? +"""Tests for the usage signal — was a surfaced record ever pulled? Covers the payload shaping and the two contracts that make this safe to leave in the hot path: telemetry never raises, and telemetry never blocks. Plus the thing -the feature exists for — that the write-path PLACE arm is now recorded, since -before this it surfaced snippets while leaving no trace anywhere. +the feature exists for — that the write-path PLACE arm is recorded, so a +snippet it surfaces leaves a trace. """ from unittest.mock import AsyncMock, MagicMock, patch @@ -13,8 +13,8 @@ import pytest pytestmark = pytest.mark.usefixtures("_no_supersession") -from scribe.services import note_usage -from scribe.services.note_usage import ( +from grimoire.services import note_usage +from grimoire.services.note_usage import ( empty_usage, record_pulled, record_surfaced, @@ -90,8 +90,8 @@ async def test_usage_for_notes_splits_counts_by_event(): from datetime import datetime, timezone ts = datetime(2026, 7, 28, tzinfo=timezone.utc) - # Rows are (note_id, event, count, last_at, ambient) since #2477 split the - # readout. Ranked and ambient surfacings arrive as separate groups. + # Rows are (note_id, event, count, last_at, ambient): ranked and ambient + # surfacings arrive as separate groups. rows = [ (3, "surfaced", 9, ts, False), (3, "surfaced", 40, ts, True), @@ -111,7 +111,7 @@ async def test_usage_for_notes_splits_counts_by_event(): out = await usage_for_notes([3]) # The dead-weight reading ("surfaced often, never pulled") is only valid # over surfacings that were CHOICES. 40 enter_project appearances must not - # make a record look popular — they sit in ambient_count (#2477). + # make a record look popular — they sit in ambient_count. assert out[3]["surfaced_count"] == 9 assert out[3]["ambient_count"] == 40 assert out[3]["pull_count"] == 2 @@ -120,7 +120,7 @@ async def test_usage_for_notes_splits_counts_by_event(): async def test_usage_for_notes_counts_what_happened_away_from_home(): """Surfaced or opened on a project other than the record's own is the - evidence a record transferred (#3735). It is a SUBSET of the totals, read + evidence a record transferred. It is a SUBSET of the totals, read from the second aggregate, and it must land on the right counter.""" from datetime import datetime, timezone @@ -167,7 +167,7 @@ async def test_no_ids_short_circuits_without_a_query(): @pytest.mark.parametrize( "lookups, expected_source", [ - # A hit AT the exact file is the sync class (#2708); a hit from the + # A hit AT the exact file is the sync class; a hit from the # directory query is the reuse-shaped place arm. Both are un-scored, # and both must leave a usage trace under their own name. ([(1, "hit"), (2, "empty")], "write_path_sync"), @@ -177,10 +177,9 @@ async def test_no_ids_short_circuits_without_a_query(): ) async def test_unscored_location_arms_are_recorded(lookups, expected_source): """The location arms carry no score, so they have no home in retrieval_logs - — they surfaced snippets while leaving no trace anywhere. That was the - blocker #2082 recorded against this task; this is the assertion that it's - closed, per class.""" - from scribe.services import plugin_context + — without a usage row they would surface snippets while leaving no trace + anywhere. Asserted per class.""" + from grimoire.services import plugin_context here = [{"id": 42, "title": "helper", "user_id": 1, "note_type": "snippet"}] responses = [(here, 1) if kind == "hit" else ([], 0) for _n, kind in lookups] @@ -214,7 +213,7 @@ async def test_unscored_location_arms_are_recorded(lookups, expected_source): async def test_auto_inject_records_what_survived_the_margin_gate(): """Not what the ranker returned — retrieval_logs already holds that. These two numbers must not silently mean different things per surface.""" - from scribe.services import plugin_context + from grimoire.services import plugin_context hits = [(0.90, fake_note(id=1, title="kept", user_id=1, note_type="snippet")), (0.40, fake_note(id=2, title="cut by the margin gate", user_id=1, note_type="snippet"))] with ( @@ -237,15 +236,13 @@ async def test_auto_inject_records_what_survived_the_margin_gate(): def test_every_getter_that_can_be_surfaced_also_records_a_pull(): - """Rule #33 contract check — and the one that would have caught #2245. + """A contract check between the two halves of the usage pair. `surfaced` and `pulled` only mean something as a PAIR: the rate between them - is what #1038 and #2085 gate on. That pair is only closed if the tool which - OPENS a record reports it. `get_note` did, `get_snippet` did, `get_task` did - NOT — and auto-inject ranks kind-blind over a corpus that is overwhelmingly - tasks and issues, so the gap sat exactly where the volume is: every surfaced - task counted as never-pulled, dragging measured pull-through toward zero for - the menu's own dominant kind. + is what pull-through is read from. That pair is only closed if every tool + which OPENS a record reports it — and auto-inject ranks kind-blind over a + corpus that is mostly tasks and issues, so one getter that does not report + counts the menu's dominant kind as never-pulled. Asserted by source inspection rather than by calling the tools, because the failure is a MISSING call — which no behavioural test of the tool's return @@ -253,9 +250,9 @@ def test_every_getter_that_can_be_surfaced_also_records_a_pull(): """ import inspect - from scribe.mcp.tools import notes as notes_tools - from scribe.mcp.tools import snippets as snippet_tools - from scribe.mcp.tools import tasks as task_tools + from grimoire.mcp.tools import notes as notes_tools + from grimoire.mcp.tools import snippets as snippet_tools + from grimoire.mcp.tools import tasks as task_tools getters = ( (notes_tools, "get_note"), @@ -272,19 +269,18 @@ def test_every_getter_that_can_be_surfaced_also_records_a_pull(): # --- persistence (integration) -------------------------------------------- # Everything above mocks _schedule or the session — deliberately, for the hot -# path. But that left the two functions that actually touch the database -# (_insert_events and usage_for_notes' real SQL) running against real Postgres -# nowhere, which is how the deployed instance reported zero for every counter -# while surfacing demonstrably fired (#2663): all-green mocked units over a -# dead real path, the #2109 shape. These two run in the CI integration lane -# and split the chain so a failure names its half. +# path. Without these, the two functions that actually touch the database +# (_insert_events and usage_for_notes' real SQL) would run against real +# Postgres nowhere — all-green mocked units over a dead real path. These two +# run in the CI integration lane and split the chain so a failure names its +# half. async def _purge(note_id: int) -> None: from sqlalchemy import delete - from scribe.models import async_session - from scribe.models.note_usage import NoteUsageEvent + from grimoire.models import async_session + from grimoire.models.note_usage import NoteUsageEvent async with async_session() as s: await s.execute( @@ -296,7 +292,7 @@ async def _purge(note_id: int) -> None: @pytest.mark.integration async def test_insert_and_readout_roundtrip_on_real_postgres(_dispose_engine): """WRITE half + READ half against the real table, one assertion per counter.""" - from scribe.services.note_usage import _insert_events + from grimoire.services.note_usage import _insert_events nid = 990101 try: @@ -309,7 +305,7 @@ async def test_insert_and_readout_roundtrip_on_real_postgres(_dispose_engine): "source": "mcp_get_snippet"}, ]) out = await usage_for_notes([nid]) - # write_path_place is a ranked choice; enter_project is ambient (#2477). + # write_path_place is a ranked choice; enter_project is ambient. assert out[nid]["surfaced_count"] == 1 assert out[nid]["ambient_count"] == 1 assert out[nid]["pull_count"] == 1 @@ -345,10 +341,10 @@ async def test_away_counts_compare_the_readers_project_with_the_records(_dispose events are all left out — each would otherwise read as transfer.""" from sqlalchemy import delete - from scribe.models import async_session - from scribe.models.note import Note - from scribe.models.project import Project - from scribe.services.note_usage import _insert_events + from grimoire.models import async_session + from grimoire.models.note import Note + from grimoire.models.project import Project + from grimoire.services.note_usage import _insert_events from tests.helpers import ensure_user async with async_session() as s: diff --git a/tests/test_notes_description_field.py b/tests/test_notes_description_field.py index 3884b8dd..e3c0818d 100644 --- a/tests/test_notes_description_field.py +++ b/tests/test_notes_description_field.py @@ -33,10 +33,10 @@ async def test_update_note_persists_description(): mock_note.project_id = None with patch( - "scribe.services.notes.async_session", + "grimoire.services.notes.async_session", return_value=_mock_session_for_update(mock_note), ): - from scribe.services.notes import update_note + from grimoire.services.notes import update_note await update_note(1, 1, description="the goal text") assert mock_note.description == "the goal text" @@ -61,9 +61,9 @@ async def test_create_note_forwards_description_to_model(): mock_session.refresh = AsyncMock() with patch( - "scribe.services.notes.async_session", return_value=mock_session - ), patch("scribe.services.notes.Note", FakeNote): - from scribe.services.notes import create_note + "grimoire.services.notes.async_session", return_value=mock_session + ), patch("grimoire.services.notes.Note", FakeNote): + from grimoire.services.notes import create_note await create_note( user_id=1, title="renew cert", description="the goal text", ) diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index 19c1edef..f42ec226 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -1,7 +1,7 @@ -"""Pattern-library coverage (#2692) — the extractor that mirrors the hook, +"""Pattern-library coverage — the extractor that mirrors the hook, the shape/record matcher, and the end-to-end measurement on real Postgres. -The extractor here and the hook's awk program (scribe_prior_art.sh ARM 1) +The extractor here and the hook's awk program (grimoire_prior_art.sh ARM 1) must agree on what counts as "a definition" — the metric and the write-path backstop are two views of the same doctrine. The EXTRACTION_VECTORS below deliberately reuse the definitions test_write_path_trigger stages for the @@ -16,7 +16,7 @@ from pathlib import Path import pytest import pytest_asyncio -from scribe.services.coverage import ( +from grimoire.services.coverage import ( ArchiveScan, class_references, coverage_line, @@ -26,7 +26,7 @@ from scribe.services.coverage import ( scannable, shapes_from_archive, ) -from scribe.services.shape_ledger import location_covers +from grimoire.services.shape_ledger import location_covers from tests.helpers import ensure_user, forge_tarball PLUGIN = Path(__file__).resolve().parents[1] / "plugin" @@ -66,11 +66,9 @@ EXTRACTION_VECTORS = [ []), ("dedup-within-file", "def f():\n pass\ndef f():\n pass\n", [("sym", "f")]), - # --- comment and string spans (#4222) ------------------------------------ + # --- comment and string spans -------------------------------------------- # Prose is not code. A wrapped docstring line beginning "class AND the" - # announced a shape called `AND` to a live session; `with` and `nobody` - # out of one module docstring in scripts/check_dangling_styles.py reached - # persisted, judged `code_shapes` rows. + # would otherwise announce a shape called `AND`. ("docstring-prose", 'def real_one():\n """Its own text is about the\n' ' class AND the to_dict, and is a def bar():\n' @@ -81,18 +79,15 @@ EXTRACTION_VECTORS = [ ("unterminated-docstring", 'def before():\n """oops, never closed\n\ndef after():\n pass\n', [("sym", "before"), ("sym", "after")]), - # The CSS half of the same defect (#2990): a wrapped comment line that + # The CSS half of the same rule: a wrapped comment line that # happens to begin with a dotted token reads as a selector. ("css-comment-selector", "/* A real base rule, not just descendants: the check reads a\n" " .ghost, class that only ever appears as an ancestor */\n.check { }\n", [("css", "check")]), - # The rest of #2990's verify list, which this scan satisfies as written: - # a comment holding a WHOLE rule defines nothing, and an unterminated - # comment does not swallow the file. The phantom it was filed for — - # `.editor-body` out of a wrapped comment in TaskEditorView.vue, a - # persisted row with a `used_by` count of zero inflating unused_css — is - # among the twenty-two this removes. + # A comment holding a WHOLE rule defines nothing, and an unterminated + # comment does not swallow the file. A phantom class out of a comment + # would persist with a `used_by` count of zero and inflate unused_css. ("comment-containing-a-whole-rule", "/* .ghost {\n color: red;\n } */\n.real { color: blue; }\n", [("css", "real")]), @@ -142,9 +137,9 @@ def test_the_hook_extractor_runs_and_agrees_line_for_line(text): """ if shutil.which("awk") is None: # pragma: no cover - env guard pytest.skip("awk not available") - lib = PLUGIN / "hooks" / "scribe_defs.sh" + lib = PLUGIN / "hooks" / "grimoire_defs.sh" out = subprocess.run( - ["bash", "-c", f'. "{lib}"; scribe_defs'], + ["bash", "-c", f'. "{lib}"; grimoire_defs'], input=text, capture_output=True, text=True, ) assert out.returncode == 0, out.stderr @@ -195,7 +190,7 @@ def test_shapes_from_archive_strips_the_wrapper_and_gates_files(): assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES -# --- unit: template class references — the CSS consumer map (milestone 302) -- +# --- unit: template class references — the CSS consumer map ---------------- def test_class_references_reads_vue_static_and_dynamic_forms_only(): @@ -229,7 +224,7 @@ def test_class_references_reads_vue_static_and_dynamic_forms_only(): def test_class_references_reads_framework_transition_names(): """A transition `name=` is a class reference: the framework applies `.toast-enter-active` and friends at runtime, so a stylesheet that - defines them is consumed even though no template spells one out (#2970). + defines them is consumed even though no template spells one out. Every spelling of the tag counts; a bound `:name` stays unknowable.""" refs = class_references( "a/T.vue", @@ -251,7 +246,7 @@ def test_class_references_reads_framework_transition_names(): def test_class_references_reads_a_concatenated_name_as_a_prefix(): """`status-${s}` cannot be resolved to one class, but its static head is real information: it is emitted as the prefix reference `status-*` so - the rows it could have built are not reported unused (#2970). A head too + the rows it could have built are not reported unused. A head too short to mean anything, or a bare separator, says nothing.""" vue = ( '
' @@ -301,7 +296,7 @@ def test_scan_archive_returns_definitions_and_references_from_one_walk(): scan = scan_archive(_tarball(tree)) assert isinstance(scan, ArchiveScan) assert [(d.path, d.kind, d.name) for d in scan.definitions] == TREE_SHAPES + [ - ("web/Card.vue", "file", "Card"), # a component is a unit (milestone 439) + ("web/Card.vue", "file", "Card"), # a component is a unit ("web/Card.vue", "css", "card"), ] # Only files whose markup names a class appear; the .py/.css files don't. @@ -309,7 +304,7 @@ def test_scan_archive_returns_definitions_and_references_from_one_walk(): assert shapes_from_archive(_tarball(tree)) == [(d.path, d.kind, d.name) for d in scan.definitions] -# --- unit: the covering predicate (lives with the ledger since #2788) -------- +# --- unit: the covering predicate (lives with the ledger) -------------------- def test_location_covers_by_exact_path_dir_prefix_and_css_dot(): @@ -341,10 +336,10 @@ def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs(): def test_type_import_specifiers_are_not_definitions(): - """#2904: `import { type Foo, bar }` is the same two words as `type Foo =` + """`import { type Foo, bar }` is the same two words as `type Foo =` and defines nothing; only a `type` line with a declaration after the name counts (TS alias, Go/Rust type).""" - from scribe.services.coverage import extract_shapes + from grimoire.services.coverage import extract_shapes src = ( 'import { type DesignSystem, fetchDesignSystems } from "@/api/designSystems";\n' 'import { type Project } from "./x";\n' @@ -375,14 +370,21 @@ def test_coverage_line_is_evidence_carrying_and_labeled_estimate(): def test_bind_repo_tool_takes_a_ref(): - """#2873: the binding names the branch the ledger follows.""" - from scribe.mcp.server import build_mcp_server + """The binding names the branch the ledger follows.""" + from grimoire.mcp.server import build_mcp_server tool = build_mcp_server()._tool_manager.get_tool("bind_repo") assert "ref" in tool.parameters.get("properties", {}) +def test_bind_repo_tool_can_move_a_renamed_repo(): + """A repo renamed on its forge carries its ledger to the new name.""" + from grimoire.mcp.server import build_mcp_server + tool = build_mcp_server()._tool_manager.get_tool("bind_repo") + assert "moved_from" in tool.parameters.get("properties", {}) + + def test_coverage_routes_are_registered(): - from scribe.app import create_app + from grimoire.app import create_app rules = {r.rule for r in create_app().url_map.iter_rules()} assert "/api/projects//coverage" in rules @@ -395,7 +397,7 @@ def test_coverage_routes_are_registered(): def _forge(tar_bytes: bytes): import httpx - from scribe.services.forge import GiteaForge + from grimoire.services.forge import GiteaForge def handler(request: httpx.Request) -> httpx.Response: path = request.url.path @@ -412,9 +414,9 @@ def _forge(tar_bytes: bytes): def _selector(tar_bytes: bytes): - """The keyring shape compute_coverage consumes since #2778 — one owner + """The keyring shape compute_coverage consumes — one owner keyring holding the mocked Gitea adapter.""" - from scribe.services.forge import ForgeSelector + from grimoire.services.forge import ForgeSelector return ForgeSelector((_forge(tar_bytes),)) @@ -422,10 +424,10 @@ def _selector(tar_bytes: bytes): @pytest_asyncio.fixture async def seeded(_dispose_engine): """User + project + binding + two snippets that cover 2 of TREE's 4 shapes.""" - from scribe.models import async_session - from scribe.models.project import Project - from scribe.services import snippets as svc - from scribe.services.repo_bindings import set_binding + from grimoire.models import async_session + from grimoire.models.project import Project + from grimoire.services import snippets as svc + from grimoire.services.repo_bindings import set_binding async with async_session() as s: user = await ensure_user(s, "coverage_itest") @@ -452,7 +454,7 @@ async def seeded(_dispose_engine): @pytest.mark.integration async def test_coverage_measures_the_tree_exactly_and_caches(seeded): - from scribe.services.coverage import ( + from grimoire.services.coverage import ( cached_coverage, compute_coverage, refresh_coverage, @@ -460,8 +462,8 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded): from sqlalchemy import select - from scribe.models import async_session - from scribe.models.code_shape import CodeShape + from grimoire.models import async_session + from grimoire.models.code_shape import CodeShape uid, pid = seeded["uid"], seeded["pid"] selector = _selector(_tarball(TREE)) @@ -482,10 +484,10 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded): assert coverage["largest_gaps"] == [ {"dir": "src", "unclassified": 2, "total": 3} ] - # #2899: a first computation has no previous stamp — nothing is "new". + # A first computation has no previous stamp — nothing is "new". assert coverage["derive_new"] == {"count": 0, "examples": []} - # The walk fed the LEDGER (#2788): every extracted shape has a row, the + # The walk fed the LEDGER: every extracted shape has a row, the # snippet reference locations are mechanically stamped canonical WITH # their snippet id, and the rest sit in the todo state. async with async_session() as s: @@ -516,16 +518,16 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded): @pytest.mark.integration async def test_ledger_keeps_judgments_and_stamps_vanished_shapes(seeded): - """The two survival rules (#2788): an agent's classification outlives + """The two survival rules: an agent's classification outlives recompute, and a shape that leaves the tree is stamped vanished — kept for history, dropped from the readout.""" from datetime import datetime, timezone from sqlalchemy import select - from scribe.models import async_session - from scribe.models.code_shape import CodeShape - from scribe.services.coverage import compute_coverage + from grimoire.models import async_session + from grimoire.models.code_shape import CodeShape + from grimoire.services.coverage import compute_coverage uid, pid = seeded["uid"], seeded["pid"] await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) @@ -567,9 +569,9 @@ async def test_ledger_keeps_judgments_and_stamps_vanished_shapes(seeded): @pytest.mark.integration async def test_enter_project_surfaces_the_line_only_once_computed(seeded): - from scribe.mcp._context import _user_id_ctx - from scribe.mcp.tools.projects import enter_project - from scribe.services.coverage import refresh_coverage + from grimoire.mcp._context import _user_id_ctx + from grimoire.mcp.tools.projects import enter_project + from grimoire.services.coverage import refresh_coverage uid, pid = seeded["uid"], seeded["pid"] token = _user_id_ctx.set(uid) @@ -593,11 +595,10 @@ async def test_enter_project_surfaces_the_line_only_once_computed(seeded): @pytest.mark.integration async def test_explicit_refresh_names_its_failures(seeded): - """refresh_for_caller (#2802) raises fixable errors instead of silence: - an agent mid-task must learn WHY nothing measured — 'None' is exactly the - stranding the button-only path caused.""" - from scribe.models import async_session - from scribe.services.coverage import refresh_for_caller + """refresh_for_caller raises fixable errors instead of silence: an agent + mid-task must learn WHY nothing measured, and 'None' would strand it.""" + from grimoire.models import async_session + from grimoire.services.coverage import refresh_for_caller uid, pid = seeded["uid"], seeded["pid"] # The owner has no forge connection rows → the error names the fix. @@ -617,9 +618,9 @@ async def test_explicit_refresh_names_its_failures(seeded): @pytest.mark.integration async def test_background_seed_is_quiet_without_a_forge(seeded): - """refresh_if_stale (#2802) must exit silently for a forge-less owner — - rule #115's baseline — and treat a fresh cache as nothing-to-do.""" - from scribe.services.coverage import refresh_coverage, refresh_if_stale + """refresh_if_stale must exit silently for a forge-less owner — the + baseline install — and treat a fresh cache as nothing-to-do.""" + from grimoire.services.coverage import refresh_coverage, refresh_if_stale uid, pid = seeded["uid"], seeded["pid"] # Absent cache + no forge rows: returns without raising, writes nothing. @@ -633,11 +634,11 @@ async def test_background_seed_is_quiet_without_a_forge(seeded): async def test_unservable_binding_measures_nothing(seeded): """A project bound only to a host the forge doesn't serve returns None — the same silence as no forge at all, never an error.""" - from scribe.services.coverage import compute_coverage - from scribe.services.repo_bindings import set_binding + from grimoire.services.coverage import compute_coverage + from grimoire.services.repo_bindings import set_binding - from scribe.models import async_session - from scribe.models.project import Project + from grimoire.models import async_session + from grimoire.models.project import Project uid = seeded["uid"] async with async_session() as s: @@ -651,14 +652,14 @@ async def test_unservable_binding_measures_nothing(seeded): assert await compute_coverage(uid, other_pid, selector=_selector(_tarball(TREE))) is None -# --- #2792: fingerprints and the proposer's readout -------------------------- +# --- fingerprints and the proposer's readout -------------------------------- def test_extract_definitions_fingerprints_each_block(): """The block rule across the language families the extractor knows: a Python def ends at the next top-level statement, a braces/CSS block keeps its closer, and comments/decorators don't move the hash.""" - from scribe.services.coverage import extract_definitions + from grimoire.services.coverage import extract_definitions text = ( "import os\n\n" @@ -682,16 +683,16 @@ def test_extract_definitions_fingerprints_each_block(): changed = {d.name: d for d in extract_definitions(text.replace("x + 1", "x + 2"))} assert changed["a"].body_sha != defs["a"].body_sha # And the identity view is unchanged for the hook mirror. - from scribe.services.coverage import extract_shapes + from grimoire.services.coverage import extract_shapes assert extract_shapes(text) == [(d.kind, d.name) for d in extract_definitions(text)] - # A CSS rule's fingerprint is its declarations (#2872): the same body + # A CSS rule's fingerprint is its declarations: the same body # under another selector is the same shape to the derive grouping. css = ".closed-msg {\n text-align: center;\n padding: 0.5rem 0;\n}\n.error-block {\n text-align: center;\n padding: 0.5rem 0;\n}\n.other {\n text-align: left;\n}\n" d = {x.name: x for x in extract_definitions(css)} assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha - # One-line rules hash their own declarations — never the empty string - # (first deploy grouped 68 unrelated one-liners as one copy) — and a - # SINGLE declaration is not a shape (#2903): it keeps its selector in the + # One-line rules hash their own declarations — never the empty string, + # which would group every unrelated one-liner as one copy — and a + # SINGLE declaration is not a shape: it keeps its selector in the # hash, so `.a { color: red }` groups only with another `.a`, never with # `.b { color: red }`. Two declarations and up stay selector-agnostic. one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.a {\n color: red;\n}\n" @@ -705,7 +706,7 @@ def test_extract_definitions_fingerprints_each_block(): def test_coverage_line_names_the_proposers_standing(): - from scribe.services.coverage import coverage_line + from grimoire.services.coverage import coverage_line base = { "total": 100, "accounted": 10, "unclassified": 90, @@ -718,7 +719,7 @@ def test_coverage_line_names_the_proposers_standing(): assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]}) assert "(1 derive group)" in line - # Milestone 302: a css top copy says what renders it; unused classes + # A css top copy says what renders it; unused classes # join the standing block only when measured (None = no evidence). line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [ {"group": "name:css:error-msg", "label": ".error-msg", "files": 6, @@ -729,7 +730,7 @@ def test_coverage_line_names_the_proposers_standing(): {"group": "name:css:x", "label": ".x", "files": 2, "consumers": {"count": 1, "paths": ["a.vue"]}}], "unused_css": None}) assert "top copy .x ×2 files · used by 1 template" in line and "unused" not in line - # #2874: the next action on the line — biggest canon queue, widest copy. + # The next action on the line — biggest canon queue, widest copy. line = coverage_line({ **base, "proposed": 40, "top_canon": {"snippet_id": 2844, "count": 78}, "derive_groups": [{"group": "dup:abc", "label": "closed-msg (identical body)", "files": 3}], @@ -738,11 +739,11 @@ def test_coverage_line_names_the_proposers_standing(): def test_coverage_line_shows_standing_work_even_with_nothing_unclassified(): - """#2899: since the scoped bucket a ledger can be fully accounted and + """A ledger can be fully accounted and still carry derive groups / proposals / divergence — the line names them as `standing:` instead of hiding them behind the todo count, and names the drift since the previous refresh first-copy-first.""" - from scribe.services.coverage import coverage_line + from grimoire.services.coverage import coverage_line base = { "total": 4693, "accounted": 4693, "unclassified": 0, @@ -773,7 +774,7 @@ def test_coverage_line_shows_standing_work_even_with_nothing_unclassified(): def test_coverage_line_names_divergence_and_recheck(): - from scribe.services.coverage import coverage_line + from grimoire.services.coverage import coverage_line base = { "total": 100, "accounted": 40, "unclassified": 60, @@ -789,10 +790,10 @@ def test_coverage_line_names_divergence_and_recheck(): def test_scoped_definitions_are_vue_script_setup_and_scoped_style_only(): - """#2869: one-offs by construction — every sym in a .vue and every css + """One-offs by construction — every sym in a .vue and every css rule inside