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
+85 -4
View File
@@ -43,10 +43,14 @@ asserted; a sentence added to it should replace one.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from scribe.services import moments
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ReplyShape:
@@ -68,6 +72,10 @@ class ReplyShape:
text: str
"""The shape itself, as the agent reads it."""
reminder: str
"""One line standing in for `text` once the session has been shown it:
the pointer form, which also serves as the reminder on later turns."""
CORE_KEY = "core"
@@ -173,13 +181,21 @@ A plan put up for review, before starting:
SHAPES: dict[str, ReplyShape] = {s.key: s for s in (
ReplyShape(CORE_KEY, "Every reply", "reply.report",
"every turn — in full once per session and after a compaction, "
"otherwise as a one-line pointer", _CORE),
"otherwise as a one-line pointer", _CORE,
"conclusion first; the shortest reply that carries the answer; place the "
"work; settle what only you can see and ask about direction; end with the "
"one thing they need to do, or say there is none."),
ReplyShape("completion", "Completion report", "work.finish",
"when a piece of work is closed", _COMPLETION),
"when a piece of work is closed", _COMPLETION,
"where this sits · what now works · how / why, and how it was verified · "
"needs you (theirs to decide, and blocking) · next."),
ReplyShape("asks", "Asking them to decide or act", "reply.ask",
"when a structured question is put to them", _ASKS),
"when a structured question is put to them", _ASKS,
"the question first and your recommendation first among the options; "
"settle what you can see, and ask only about direction."),
ReplyShape("plan", "A plan for review", "work.plan",
"when a plan is opened", _PLAN),
"when a plan is opened", _PLAN,
"goal · steps · how you will know it works · open questions."),
)}
@@ -205,3 +221,68 @@ def catalog() -> dict:
],
"total": len(SHAPES),
}
# ── Delivery: what a door puts in front of the agent ────────────────────
# Said once, in the full form: the default is a floor an operator can raise.
_FULL_HEAD = ("Reply shape · {title} — the default shape for {what}. Where an operator "
"preference shown beside it differs, the preference is what they asked for.")
_POINTER = ("Reply shape · {title}, shown in full earlier this session "
"(list_reply_shapes has it): {reminder}")
FULL = "full"
POINTER = "pointer"
def render(shape: ReplyShape, *, full: bool) -> str:
"""The shape as a door delivers it: in full the first time a session sees
it, as its one-line reminder after that."""
if not full:
return _POINTER.format(title=shape.title, reminder=shape.reminder)
what = moments.MOMENTS[shape.moment].means
return _FULL_HEAD.format(title=shape.title, what=what) + "\n\n" + shape.text
def deliver(shapes: list[ReplyShape], seen: set[str] | frozenset[str]) -> tuple[list[str], dict[str, str]]:
"""Render these shapes against what the session was already shown.
Returns the blocks and `{key: form}` — every shape delivered and whether
it went out in full or as a pointer. The keys sent in full are the ones a
door's ledger appends; a door with no ledger passes `seen` empty and
every shape goes out in full.
"""
blocks, forms = [], {}
for shape in shapes:
full = shape.key not in seen
blocks.append(render(shape, full=full))
forms[shape.key] = FULL if full else POINTER
return blocks, forms
def parse_seen(raw: str | None) -> frozenset[str]:
"""A ledger's comma-separated keys, keeping only shapes that exist."""
return frozenset(k for k in (p.strip() for p in (raw or "").split(",")) if k in SHAPES)
async def record_delivery(user_id: int | None, forms: dict[str, str], *, via: str) -> None:
"""One `reply_shape` row per delivery: which shapes, in which form, by which
door. Fire-and-forget — telemetry never takes a shape away from the reader.
In AppLog beside 409's `report_check`, so the two readings of one reply —
was the shape delivered, did the reply carry it — sit in one place.
"""
if not forms:
return
try:
from scribe.models import async_session
from scribe.models.app_log import AppLog
async with async_session() as session:
session.add(AppLog(
category="plugin", user_id=user_id, action="reply_shape",
details=json.dumps({"shapes": forms, "via": via}),
))
await session.commit()
except Exception: # noqa: BLE001 - observation never breaks the observed
logger.debug("reply shape delivery not recorded", exc_info=True)