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
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user