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
+12 -1
View File
@@ -28,6 +28,7 @@ from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import reply_shapes as reply_shapes_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
@@ -73,11 +74,16 @@ async def enter_project(project_id: int) -> dict:
project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
design_system, project_rules, pattern_coverage —
design_system, project_rules, pattern_coverage, reply_shape —
plus unplanned_milestones, milestone_summary_omitted,
unplanned_milestones_omitted, family, inception and systems_bootstrap,
each present only when it applies (see below).
`reply_shape` is the default shape of every reply you write to the
operator: conclusion first, the shortest reply that carries the answer,
where the work stands, and the one thing they need to do. Write to it;
an operator preference about replies that differs from it wins.
`project` is id, title, status and the full goal. get_project has the
whole record.
@@ -323,6 +329,11 @@ async def enter_project(project_id: int) -> dict:
out["family"] = family
if inception_ask:
out["inception"] = inception_ask
# The core reply shape (milestone 500): for a client with no prompt hook,
# entering the project is the one point every session passes before it
# writes a reply. A plugin session gets it with its first turn as well;
# one repeat per session is the price of reaching every client.
out["reply_shape"] = reply_shapes_svc.render(reply_shapes_svc.core(), full=True)
return out
+3 -2
View File
@@ -416,8 +416,9 @@ async def update_task(
reconstructing them, because a remembered milestone title or "next step"
reads exactly like a real one when it is wrong.
Closing a task (done or cancelled) also returns `report_back`: a one-line
reminder of what the reply to the operator should cover. When the
Closing a task (done or cancelled) also returns `reply_shape`, the
default shape of the completion report you are about to write, and
`report_back`: a one-line reminder of what that reply should cover. When the
operator has preferences for how a completion report is written, they
come back as `reply_preferences` ({id, title, statement, kind}) — found
by their `when_to_apply`, so a preference whose trigger is writing the
+36 -4
View File
@@ -15,6 +15,7 @@ from scribe.config import Config
from scribe.services import lesson_rules as lesson_rules_svc
from scribe.services import moment_delivery as moment_delivery_svc
from scribe.services import plugin_context as plugin_ctx_svc
from scribe.services import reply_shapes as reply_shapes_svc
from scribe.services import repo_bindings as repo_bindings_svc
from scribe.services import report_check as report_check_svc
from scribe.services import rule_moment_judgments as judgments_svc
@@ -126,6 +127,17 @@ async def autoinject_retrieve():
different things about what the reader holds —
so they get different lines (#4100). Shares the
ledger directory and the same clear-on-compact.
shapes_seen (opt) — comma-separated reply-shape keys this session was
already shown in full (milestone 500). The core
goes out in full when `core` is absent, as its
one-line reminder when present; the hook's
ledger is swept on compaction, which is how the
full core comes back after one.
THE TURN'S REPLY SHAPE COMES FIRST (milestone 500 step 3). Every turn ends
in a reply and this is the last point before it is written, so the core
shape and whatever is mounted on `reply.report` lead the payload. Fresh
shape keys come back as `shape_keys` for the ledger.
TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query
but nothing else: the notes menu can be disabled, thresholded and top-k'd
@@ -159,9 +171,19 @@ async def autoinject_retrieve():
g.user.id, result.get("lesson_ids") or [], rules.get("shown_rule_ids") or [],
arm="p", situation=q, project_id=project_id or None,
)
blocks = [b for b in (rules["context"], result["context"], proposal) if b]
# The reply mounts go through the same rule ledger; a rule the prompt arm
# just named is not quoted a second time by the turn's delivery.
turn = await moment_delivery_svc.deliver_for_turn(
g.user.id, seen=reply_shapes_svc.parse_seen(request.args.get("shapes_seen")),
project_id=project_id,
exclude=frozenset(exclude_rule_ids) | frozenset(rules["rule_ids"]),
held=frozenset(held_rule_ids),
)
blocks = [b for b in (turn["context"], rules["context"], result["context"], proposal) if b]
result["context"] = "\n\n".join(blocks)
result["rule_ids"] = rules["rule_ids"]
result["rule_ids"] = list(dict.fromkeys([*rules["rule_ids"], *turn["rule_ids"]]))
result["shape_keys"] = [k for k, form in turn["shape_forms"].items()
if form == reply_shapes_svc.FULL]
return jsonify(result)
@@ -275,12 +297,16 @@ async def moment():
`rule_ids` (the FRESH ones, for the hook to append to the ledger) and
`moments` (the names reached). A lookup, not a ranked search: no
retrieval_logs row; each fresh rule is recorded surfaced with its moment.
A moment that carries a default reply shape (milestone 500) brings it too,
ahead of the rules: in full unless `shapes_seen` names it, then as its
reminder. `shape_keys` are the ones sent in full, for the hook's ledger.
"""
event = await request.get_json(silent=True) or {}
tool = str(event.get("tool_name") or "").strip()
tool_input = event.get("tool_input")
if not tool:
return jsonify({"context": "", "rule_ids": [], "moments": []})
return jsonify({"context": "", "rule_ids": [], "moments": [], "shape_keys": []})
project_id, _repo, _unbound = await _project_scope()
reached, result = await moment_delivery_svc.deliver_for_act(
g.user.id, tool, tool_input if isinstance(tool_input, dict) else {},
@@ -288,10 +314,16 @@ async def moment():
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
)
shapes, forms = moment_delivery_svc.shapes_for_act(
reached, reply_shapes_svc.parse_seen(request.args.get("shapes_seen")),
)
await reply_shapes_svc.record_delivery(g.user.id, forms, via="hook")
context = "\n\n".join(b for b in ("\n\n".join(shapes), "\n".join(result.lines)) if b)
return jsonify({
"context": "\n".join(result.lines),
"context": context,
"rule_ids": result.rule_ids,
"moments": [hit["moment"] for hit in reached],
"shape_keys": [k for k, form in forms.items() if form == reply_shapes_svc.FULL],
})
+73 -10
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
import logging
from scribe.services import moment_actions
from scribe.services import moment_actions, reply_shapes
from scribe.services import retrieval_pipeline as rp
logger = logging.getLogger(__name__)
@@ -38,23 +38,27 @@ async def reachable_tools(user_id: int) -> list[str]:
What the plugin's catch-all hook reads once per session window, so the
calls that cannot reach a mounted rule — most of them, and every one on
an install that has mounted nothing — never leave the machine. An action
counts only when its moment carries a mount. The skill loader counts
whenever anything is mounted: a stored process declares its own moments,
which only the load itself can resolve, and a load is rare enough that
asking costs nothing.
counts when its moment carries a mount or a default reply shape. The
skill loader counts whenever anything is mounted: a stored process
declares its own moments, which only the load itself can resolve, and a
load is rare enough that asking costs nothing.
"""
from scribe.services import rulebooks
mounted = await rulebooks.mounted_moments(user_id)
if not mounted:
return []
# A moment that carries a default reply shape (milestone 500) is worth a
# request whether or not anything is mounted on it: the shape is product,
# so every install has it.
shaped = {s.moment for s in reply_shapes.SHAPES.values() if s.key != reply_shapes.CORE_KEY}
mounted = set(await rulebooks.mounted_moments(user_id))
wanted = mounted | shaped
mappings = await moment_actions.list_mappings(user_id)
keys = {
moment_actions.tool_key(action.tool)
for action, _via in moment_actions.effective_actions(mappings)
if action.moment in mounted
if action.moment in wanted
}
keys.add(moment_actions.SKILL_TOOL)
if mounted:
keys.add(moment_actions.SKILL_TOOL)
return sorted(keys)
@@ -86,6 +90,58 @@ async def deliver_for_act(
return reached, result
def shapes_for_act(reached: list[dict], seen: frozenset[str] = frozenset()) -> tuple[list[str], dict[str, str]]:
"""The default reply shapes riding the moments this act reached (milestone 500).
Closing a task comes just before a completion report, a structured
question is an ask, opening a plan comes before a plan is put up for
review — so the shape for that reply arrives at the act, before the reply
is written. In full the first time a session meets it, as its reminder
after that; a door with no ledger passes `seen` empty.
"""
return reply_shapes.deliver(
reply_shapes.for_moments([hit["moment"] for hit in reached]), seen,
)
# What the per-turn delivery says reached `reply.report`: the turn has not
# ended yet, but every turn ends in a reply, and this is the last point
# before it is written.
_REACHED_BY_TURN = "the reply this turn will end with"
async def deliver_for_turn(
user_id: int, *, seen: frozenset[str] = frozenset(), project_id: int | None = None,
exclude: frozenset[int] = frozenset(), held: frozenset[int] = frozenset(),
) -> dict:
"""What every turn carries before its reply is written (milestone 500 step 3).
The core reply shape — in full once per session and again after a
compaction (the ledger is swept then), otherwise its one-line reminder —
and the rules and preferences mounted on `reply.report`, under the same
rule ledger as every other arm. The Stop hook's reply moment fires after
the reply exists, which is too late to shape it and is kept as the
backstop; this is the point before.
Returns `{context, rule_ids, shape_forms}`. Fails open to the core alone,
and the core itself never fails: it is a constant.
"""
blocks, forms = reply_shapes.deliver([reply_shapes.core()], seen)
rule_ids: list[int] = []
try:
reached = [{"moment": "reply.report", "tool": "", "match": _REACHED_BY_TURN,
"via": moment_actions.DEFAULT}]
result = await deliver_moments(
user_id, reached, project_id=project_id, exclude=exclude, held=held,
)
blocks.extend(result.lines)
rule_ids = result.rule_ids
except Exception: # noqa: BLE001 - the mounted half never costs the core
logger.debug("reply.report mounts not delivered for the turn", exc_info=True)
await reply_shapes.record_delivery(user_id, forms, via="turn")
return {"context": "\n\n".join(blocks), "rule_ids": rule_ids, "shape_forms": forms}
async def attach_moment_rules(
user_id: int, tool: str, arguments: dict | None, data: dict,
) -> dict:
@@ -114,6 +170,13 @@ async def attach_moment_rules(
"rule_ids": result.shown_rule_ids,
"open_with": "get_rule(id)",
}
# The reply shape for this moment, in full: no ledger reaches this
# door, and an act like closing a task is rare enough that the shape
# arriving each time costs less than one report written without it.
blocks, forms = shapes_for_act(_reached)
if blocks:
data["reply_shape"] = "\n\n".join(blocks)
await reply_shapes.record_delivery(user_id, forms, via="mcp")
except Exception: # noqa: BLE001 - a decoration never breaks the payload
logger.debug("moment rules for %s could not be attached", tool, exc_info=True)
return data
+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)