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
+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"]