feat(500): reply shapes are delivered - the core every turn through the ledger, each slice at its moment, reply mounts before the reply (#5495)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m31s
CI & Build / Build & push image (push) Skipped

- Every turn (/api/plugin/retrieve, UserPromptSubmit): the core reply shape
  leads the payload - in full the first time, as its one-line reminder
  after that - followed by whatever is mounted on reply.report, under the
  shared rule ledger. Fresh keys come back as shape_keys.
- The ledger is <sid>.shapes.ids in scribe-priorart (scribe_shapes_file /
  _seen / _append), so the compaction sweep that clears every .ids ledger
  is what brings the full core back after one.
- At a moment (/api/plugin/moment): the slice for that reply - completion
  on work.finish, asks on reply.ask, plan on work.plan - ahead of the
  mounted rules. reachable_tools now lists tools reaching a shaped moment
  even on an install with nothing mounted.
- Scribe's own tools (attach_moment_rules): reply_shape in the response,
  in full, since that door has no ledger. enter_project carries the core
  for clients with no prompt hook.
- Telemetry: one AppLog row per delivery (plugin / reply_shape), each
  shape with full or pointer and the door (turn, hook, mcp).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:45:25 -04:00
co-authored by Claude Opus 5.5
parent 9fedcbea3d
commit eadb08c347
14 changed files with 620 additions and 37 deletions
+14
View File
@@ -102,6 +102,20 @@ def _no_supersession():
yield
@pytest.fixture(autouse=True)
def _no_reply_shape_log():
"""Stub the reply-shape delivery row (milestone 500 step 3).
Autouse for _no_system_labels' reason: every turn's retrieval, every
moment request and every Scribe tool that reaches a shaped moment writes
one AppLog row, and those paths are tested without a database.
tests/test_reply_shapes.py binds the real function at import time,
before this patch runs.
"""
with patch("scribe.services.reply_shapes.record_delivery", AsyncMock()):
yield
@pytest.fixture(autouse=True)
def _no_system_labels():
"""Stub the menu's "which System is each line about?" lookup (#4364).
+130 -14
View File
@@ -185,18 +185,23 @@ async def _reachable(mounted, mappings=()):
return await md.reachable_tools(1)
async def test_an_install_with_nothing_mounted_keeps_the_hook_off_the_wire():
listing = AsyncMock()
with patch.object(rulebooks, "mounted_moments", AsyncMock(return_value=set())), \
patch.object(moment_actions, "list_mappings", listing):
assert await md.reachable_tools(1) == []
listing.assert_not_awaited()
# 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.
SHAPED = ["askuserquestion", "create_milestone", "enterplanmode", "exitplanmode",
"skill", "start_planning", "update_milestone", "update_task"]
async def test_only_the_tools_whose_moments_carry_a_mount_are_listed():
assert await _reachable({"work.deliver"}) == ["bash", "skill"]
assert await _reachable({"work.finish"}) == ["skill", "update_milestone", "update_task"]
assert await _reachable({"skill.release"}) == ["skill"]
async def test_an_install_with_nothing_mounted_still_asks_about_the_shaped_moments():
"""The reply shapes are product, so every install has them: the hook asks
about the acts that bring one, and about nothing else."""
assert await _reachable(set()) == SHAPED
async def test_only_the_tools_whose_moments_carry_a_mount_or_a_shape_are_listed():
assert await _reachable({"work.deliver"}) == sorted([*SHAPED, "bash"])
assert await _reachable({"work.finish"}) == SHAPED
assert await _reachable({"skill.release"}) == SHAPED
assert "bash" not in await _reachable(set())
async def test_the_skill_loader_counts_whenever_anything_is_mounted():
@@ -232,7 +237,8 @@ async def test_the_route_reads_the_event_and_the_ledger():
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
resp = await routes.moment.__wrapped__()
body = await resp.get_json()
assert body == {"context": "a line", "rule_ids": [5], "moments": ["work.deliver"]}
assert body == {"context": "a line", "rule_ids": [5], "moments": ["work.deliver"],
"shape_keys": []}
args, kw = deliver.await_args
assert args == (7, "Bash", {"command": "git push"})
assert kw == {"project_id": 3, "exclude": frozenset({9}), "held": frozenset({4})}
@@ -245,7 +251,8 @@ async def test_the_route_answers_an_empty_event_with_nothing():
async with app.test_request_context("/api/plugin/moment", method="POST", json={}):
g.user = SimpleNamespace(id=7)
resp = await routes.moment.__wrapped__()
assert await resp.get_json() == {"context": "", "rule_ids": [], "moments": []}
assert await resp.get_json() == {"context": "", "rule_ids": [], "moments": [],
"shape_keys": []}
def test_both_routes_are_on_the_app():
@@ -273,13 +280,15 @@ def test_the_hook_and_the_routes_agree_on_every_name():
assert "scribe_rules_live" in src and "scribe_rules_append" in src
# The SHARED ledger, not one of its own.
assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src and ".rules.ids" in src
for field in (".tools", ".rule_ids", ".context"):
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
from scribe.routes import plugin as routes
route = inspect.getsource(routes.moment)
for name in ("exclude_rule_ids", "held_rule_ids", "tool_name", "tool_input"):
for name in ("exclude_rule_ids", "held_rule_ids", "tool_name", "tool_input",
"shapes_seen", "shape_keys"):
assert name in route
@@ -380,3 +389,110 @@ def test_every_scribe_tool_the_defaults_name_attaches_its_moment_rules():
f"attach its mounted rules — a client without the plugin would never "
f"receive them"
)
# ── reply shapes ride the moments (milestone 500 step 3) ────────────────
from scribe.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"}]
def test_an_act_brings_the_shape_for_the_reply_it_comes_before():
blocks, forms = md.shapes_for_act(FINISH)
assert forms == {"completion": reply_shapes.FULL}
assert reply_shapes.SHAPES["completion"].text in blocks[0]
_blocks, forms = md.shapes_for_act(ASK, frozenset({"asks"}))
assert forms == {"asks": reply_shapes.POINTER}
def test_an_act_at_an_unshaped_moment_brings_no_shape():
assert md.shapes_for_act(PUSH) == ([], {})
async def test_a_turn_carries_the_core_in_full_until_the_session_holds_it():
with patch.object(md, "deliver_moments", AsyncMock(return_value=rp.RuleResult())):
first = await md.deliver_for_turn(1)
later = await md.deliver_for_turn(1, seen=frozenset({"core"}))
assert first["shape_forms"] == {"core": reply_shapes.FULL}
assert reply_shapes.core().text in first["context"]
assert later["shape_forms"] == {"core": reply_shapes.POINTER}
assert reply_shapes.core().text not in later["context"]
assert reply_shapes.core().reminder in later["context"]
async def test_a_turn_brings_what_is_mounted_on_reply_report_after_the_core():
deliver = AsyncMock(return_value=rp.RuleResult(lines=["Preference that may apply"], rule_ids=[173]))
with patch.object(md, "deliver_moments", deliver):
out = await md.deliver_for_turn(1, project_id=2, exclude=frozenset({9}), held=frozenset({4}))
assert out["context"].index(reply_shapes.core().title) < out["context"].index("Preference")
assert out["rule_ids"] == [173]
reached = deliver.await_args.args[1]
assert [hit["moment"] for hit in reached] == ["reply.report"]
assert deliver.await_args.kwargs == {"project_id": 2, "exclude": frozenset({9}),
"held": frozenset({4})}
async def test_a_failed_mount_lookup_still_delivers_the_core():
with patch.object(md, "deliver_moments", AsyncMock(side_effect=RuntimeError("db down"))):
out = await md.deliver_for_turn(1)
assert reply_shapes.core().text in out["context"] and out["rule_ids"] == []
async def test_a_turn_records_its_delivery():
with patch.object(md, "deliver_moments", AsyncMock(return_value=rp.RuleResult())):
await md.deliver_for_turn(1, seen=frozenset({"core"}))
reply_shapes.record_delivery.assert_awaited_with(1, {"core": reply_shapes.POINTER}, via="turn")
async def test_a_task_closed_through_the_tool_carries_the_completion_shape():
data = {"id": 40, "status": "done", "project_id": 2}
out = await _with(_stub_db([]), lambda: md.attach_moment_rules(
1, "update_task", {"status": "done"}, data,
))
assert reply_shapes.SHAPES["completion"].text in out["reply_shape"]
assert "moment_rules" not in out # nothing mounted, still shaped
async def test_an_unshaped_act_through_a_tool_carries_no_shape():
out = await _with(_stub_db([]), lambda: md.attach_moment_rules(
1, "create_note", {"project_id": 2}, {"id": 3},
))
assert "reply_shape" not in out
async def test_the_route_puts_the_shape_ahead_of_the_rules_and_returns_its_key():
from scribe.routes import plugin as routes
deliver = AsyncMock(return_value=(ASK, rp.RuleResult(lines=["a line"], rule_ids=[5])))
app = Quart(__name__)
event = {"tool_name": "AskUserQuestion", "tool_input": {}}
async with app.test_request_context("/api/plugin/moment", method="POST", json=event):
g.user = SimpleNamespace(id=7)
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
body = await (await routes.moment.__wrapped__()).get_json()
assert body["shape_keys"] == ["asks"]
assert body["context"].index("Who decides what") < body["context"].index("a line")
async with app.test_request_context("/api/plugin/moment", method="POST", json=event,
query_string={"shapes_seen": "asks"}):
g.user = SimpleNamespace(id=7)
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
body = await (await routes.moment.__wrapped__()).get_json()
assert body["shape_keys"] == []
assert "Who decides what" not in body["context"]
def test_the_hook_carries_the_shape_ledger_between_calls(tmp_path):
replies = {"/api/plugin/moment-tools": b'{"tools":["askuserquestion"]}',
"/api/plugin/moment": json.dumps(
{"context": "Reply shape", "rule_ids": [], "moments": ["reply.ask"],
"shape_keys": ["asks"]}).encode()}
with http_sink(by_path=replies) as (port, seen):
_hook(tmp_path, port, tool="AskUserQuestion")
_hook(tmp_path, port, tool="AskUserQuestion")
moment_calls = [e for e in seen if e["_path"] == "/api/plugin/moment"]
assert "shapes_seen" not in moment_calls[0]
assert moment_calls[1]["shapes_seen"] == ["asks"]
+129
View File
@@ -0,0 +1,129 @@
"""The core reply shape rides every turn (milestone 500 step 3).
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 scribe.services import reply_shapes
from tests.helpers import http_sink, need_tools
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_autoinject.sh"
SESSION_START = ROOT / "plugin" / "hooks" / "scribe_session_context.sh"
# ── the route ────────────────────────────────────────────────────────────
async def _retrieve(query_string, turn_context="CORE SHAPE"):
from scribe.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"], "SCRIBE_URL": f"http://127.0.0.1:{port}",
"SCRIBE_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():
"""Rule 33 across the seam: a renamed field fails silently."""
from scribe.routes import plugin as routes
hook = HOOK.read_text()
assert "shapes_seen" in hook and "'.shape_keys'" in hook
assert "scribe_shapes_file" in hook and "scribe_shapes_append" in hook
route = inspect.getsource(routes.autoinject_retrieve)
assert 'request.args.get("shapes_seen")' in route and '"shape_keys"' in route
+92
View File
@@ -10,6 +10,9 @@ import pytest
from scribe.services import moments, reply_shapes
from tests.helpers import FakeMCP, dev_only_hits
# Bound before conftest's autouse stub replaces the module attribute.
_REAL_RECORD = reply_shapes.record_delivery
def test_there_is_a_core_and_at_least_one_slice():
"""The sweeps below are vacuous over an empty catalog (rule 167)."""
@@ -148,3 +151,92 @@ async def test_the_route_returns_the_catalog():
g.user = SimpleNamespace(id=7)
resp = await routes.reply_shapes_route.__wrapped__()
assert await resp.get_json() == reply_shapes.catalog()
# ── delivery (milestone 500 step 3) ─────────────────────────────────────
@pytest.mark.parametrize("key", list(reply_shapes.SHAPES))
def test_every_shape_has_a_one_line_reminder(key):
shape = reply_shapes.SHAPES[key]
assert shape.reminder.strip() and "\n" not in shape.reminder
assert not dev_only_hits(shape.reminder), key
def test_the_full_form_carries_the_text_and_says_a_preference_wins():
core = reply_shapes.core()
out = reply_shapes.render(core, full=True)
assert out.endswith(core.text)
assert core.title in out and "preference" in out
def test_the_pointer_form_is_one_line_carrying_the_reminder():
core = reply_shapes.core()
out = reply_shapes.render(core, full=False)
assert "\n" not in out
assert core.reminder in out and "list_reply_shapes" in out
assert core.text not in out
def test_a_shape_the_session_holds_goes_out_as_its_pointer():
shapes = [reply_shapes.core(), reply_shapes.SHAPES["completion"]]
blocks, forms = reply_shapes.deliver(shapes, frozenset({"core"}))
assert forms == {"core": reply_shapes.POINTER, "completion": reply_shapes.FULL}
assert reply_shapes.core().text not in blocks[0]
assert reply_shapes.SHAPES["completion"].text in blocks[1]
def test_a_door_with_no_ledger_sends_everything_in_full():
_blocks, forms = reply_shapes.deliver([reply_shapes.core()], frozenset())
assert forms == {"core": reply_shapes.FULL}
@pytest.mark.parametrize("raw,keys", [
("core", {"core"}),
("core, asks,core", {"core", "asks"}),
("", set()),
(None, set()),
("core,nonsense,../x", {"core"}),
])
def test_the_ledger_keeps_only_shapes_that_exist(raw, keys):
assert reply_shapes.parse_seen(raw) == frozenset(keys)
async def test_a_delivery_is_one_plugin_row_naming_each_shape_and_its_form():
from unittest.mock import MagicMock, patch
added = []
session = MagicMock()
session.add = added.append
async def _commit():
return None
session.commit = _commit
class _Ctx:
async def __aenter__(self):
return session
async def __aexit__(self, *exc):
return False
with patch("scribe.models.async_session", lambda: _Ctx()):
await _REAL_RECORD(7, {"core": "pointer", "asks": "full"}, via="turn")
assert len(added) == 1
row = added[0]
assert (row.category, row.action, row.user_id) == ("plugin", "reply_shape", 7)
import json
assert json.loads(row.details) == {"shapes": {"core": "pointer", "asks": "full"},
"via": "turn"}
async def test_the_delivery_row_fails_open_and_skips_an_empty_delivery():
from unittest.mock import patch
def _boom():
raise RuntimeError("db down")
with patch("scribe.models.async_session", _boom):
await _REAL_RECORD(7, {"core": "full"}, via="turn") # no raise
await _REAL_RECORD(7, {}, via="turn")
+1 -1
View File
@@ -58,7 +58,7 @@ DEFS = HOOKS / "scribe_defs.sh"
# the convention tests below are what keep this roster honest as it grows.
LEDGERS = {
"scribe-priorart": (".ids", ".rules.ids", ".opened.ids", ".sync.ids",
".derive.ids"),
".derive.ids", ".shapes.ids"),
"scribe-autoinject": (".ids",),
}