CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
Step 3 of the Scribe -> Grimoire rename. - Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*. - hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR / SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`. - skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes arrive as grimoire-proc-<slug>; moment_actions names them the same way. - Migration 0124 rewrites stored skill names (rule_moments, rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing. - Settings' install commands and MCP-only defaults, the docs and the tests follow. - scripts/check_plugin.py drops the smoke event for a report-check hook that has no file in plugin/hooks. Kept on purpose: the secret-sealing label, and the names that let backups written as Scribe restore and be counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
5.6 KiB
Python
130 lines
5.6 KiB
Python
"""The core reply shape rides every turn.
|
|
|
|
Every turn ends in a reply, and the operator's prompt is the last point before
|
|
it is written, so the per-turn retrieval carries the core shape ahead of
|
|
everything else: in full the first time a session sees it, as its one-line
|
|
reminder after that, and in full again once a compaction has swept the
|
|
ledger. What is pinned here is that round trip across the shell/Python seam —
|
|
the route's order and keys, and the hook's ledger.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from quart import Quart, g
|
|
|
|
from grimoire.services import reply_shapes
|
|
from tests.helpers import http_sink, need_tools
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
HOOK = ROOT / "plugin" / "hooks" / "grimoire_autoinject.sh"
|
|
SESSION_START = ROOT / "plugin" / "hooks" / "grimoire_session_context.sh"
|
|
|
|
|
|
# ── the route ────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _retrieve(query_string, turn_context="CORE SHAPE"):
|
|
from grimoire.routes import plugin as routes
|
|
|
|
turn = AsyncMock(return_value={"context": turn_context, "rule_ids": [173],
|
|
"shape_forms": {"core": reply_shapes.FULL}})
|
|
app = Quart(__name__)
|
|
async with app.test_request_context("/api/plugin/retrieve", query_string=query_string):
|
|
g.user = SimpleNamespace(id=7)
|
|
with patch.object(routes.plugin_ctx_svc, "build_prompt_rule_hint",
|
|
AsyncMock(return_value={"context": "RULE LINE", "rule_ids": [11],
|
|
"shown_rule_ids": [11]})), \
|
|
patch.object(routes.plugin_ctx_svc, "build_autoinject_hint",
|
|
AsyncMock(return_value={"context": "NOTES MENU", "note_ids": []})), \
|
|
patch.object(routes.lesson_rules_svc, "co_surfaced", AsyncMock(return_value="")), \
|
|
patch.object(routes.moment_delivery_svc, "deliver_for_turn", turn):
|
|
resp = await inspect.unwrap(routes.autoinject_retrieve)()
|
|
return await resp.get_json(), turn
|
|
|
|
|
|
async def test_the_turns_shape_leads_the_payload_and_its_key_comes_back():
|
|
body, _turn = await _retrieve({"q": "fix the flaky thing"})
|
|
ctx = body["context"]
|
|
assert ctx.index("CORE SHAPE") < ctx.index("RULE LINE") < ctx.index("NOTES MENU")
|
|
assert body["shape_keys"] == ["core"]
|
|
# Both arms' fresh rules reach the shared ledger.
|
|
assert body["rule_ids"] == [11, 173]
|
|
|
|
|
|
async def test_the_route_hands_the_ledger_and_the_rules_just_named_to_the_turn():
|
|
_body, turn = await _retrieve({"q": "x", "shapes_seen": "core,bogus",
|
|
"exclude_rule_ids": "9", "held_rule_ids": "4"})
|
|
kw = turn.await_args.kwargs
|
|
assert kw["seen"] == frozenset({"core"})
|
|
# A rule the prompt arm just named is not quoted again by the turn.
|
|
assert kw["exclude"] == frozenset({9, 11})
|
|
assert kw["held"] == frozenset({4})
|
|
|
|
|
|
# ── the hook ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _env(tmp_path, port):
|
|
return {"PATH": os.environ["PATH"], "GRIMOIRE_URL": f"http://127.0.0.1:{port}",
|
|
"GRIMOIRE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
|
|
|
|
|
|
def _prompt(tmp_path, port, session="s-shape"):
|
|
need_tools("bash", "curl", "awk")
|
|
out = subprocess.run(
|
|
["bash", str(HOOK)],
|
|
input=json.dumps({"session_id": session, "cwd": str(tmp_path),
|
|
"prompt": "please fix the importer"}),
|
|
capture_output=True, text=True, env=_env(tmp_path, port), timeout=30,
|
|
)
|
|
assert out.returncode == 0, out.stderr
|
|
return out.stdout
|
|
|
|
|
|
def _compact(tmp_path, session="s-shape"):
|
|
out = subprocess.run(
|
|
["bash", str(SESSION_START)],
|
|
input=json.dumps({"session_id": session, "source": "compact"}),
|
|
capture_output=True, text=True,
|
|
env={"PATH": os.environ["PATH"], "HOME": str(tmp_path), "TMPDIR": str(tmp_path)},
|
|
timeout=60,
|
|
)
|
|
assert out.returncode == 0, out.stderr
|
|
|
|
|
|
REPLY = json.dumps({"context": "Reply shape · Every reply", "note_ids": [], "rule_ids": [],
|
|
"shape_keys": ["core"]}).encode()
|
|
|
|
|
|
def test_the_core_goes_whole_once_then_as_a_reminder_then_whole_after_a_compaction(tmp_path):
|
|
with http_sink(by_path={"/api/plugin/retrieve": REPLY}) as (port, seen):
|
|
out = _prompt(tmp_path, port)
|
|
_prompt(tmp_path, port)
|
|
_compact(tmp_path)
|
|
_prompt(tmp_path, port)
|
|
asks = [e for e in seen if e["_path"] == "/api/plugin/retrieve"]
|
|
assert len(asks) == 3
|
|
assert "shapes_seen" not in asks[0]
|
|
assert asks[1]["shapes_seen"] == ["core"]
|
|
# The compaction swept the ledger: the session no longer holds the shape.
|
|
assert "shapes_seen" not in asks[2]
|
|
assert "Every reply" in json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
|
|
|
|
def test_the_hook_and_the_route_agree_on_the_ledgers_names():
|
|
"""A contract across the seam: a renamed field fails silently."""
|
|
from grimoire.routes import plugin as routes
|
|
|
|
hook = HOOK.read_text()
|
|
assert "shapes_seen" in hook and "'.shape_keys'" in hook
|
|
assert "grimoire_shapes_file" in hook and "grimoire_shapes_append" in hook
|
|
route = inspect.getsource(routes.autoinject_retrieve)
|
|
assert 'request.args.get("shapes_seen")' in route and '"shape_keys"' in route
|