Reply shapes ride the moments (milestone 500, steps 1–5) #209

Merged
bvandeusen merged 13 commits from dev into main 2026-10-09 16:40:04 -04:00
14 changed files with 620 additions and 37 deletions
Showing only changes of commit eadb08c347 - Show all commits
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.09.1835",
"version": "2026.10.09.1845",
"author": {
"name": "Bryan Van Deusen"
},
+9
View File
@@ -110,6 +110,7 @@ rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$rule_state_dir" 2>/dev/null || true
idfile=""
rulefile=""
shapefile=""
exclude_q=""
if [ -n "$session_id" ]; then
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
@@ -127,6 +128,11 @@ if [ -n "$session_id" ]; then
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
# What the session actually OPENED, as against what it was shown (#4100).
exclude_q="${exclude_q}$(scribe_held_query "$rule_state_dir/${safe_sid}.opened.ids")"
# Which reply shapes it already holds in full (milestone 500): the core is
# sent whole once, and as its one-line reminder on every later turn.
shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile")
[ -n "$shapes_seen" ] && exclude_q="${exclude_q}&shapes_seen=${shapes_seen}"
fi
body=$(curl -fsS --max-time 5 \
@@ -150,6 +156,9 @@ if [ -n "$rulefile" ]; then
scribe_json_list "$body_flat" '.rule_ids' \
| scribe_rules_append "$rulefile"
fi
if [ -n "$shapefile" ]; then
scribe_json_list "$body_flat" '.shape_keys' | scribe_shapes_append "$shapefile"
fi
scribe_json_out UserPromptSubmit "$context"
exit 0
+29
View File
@@ -1208,6 +1208,35 @@ scribe_written_append() {
return 0
}
# THE REPLY-SHAPE LEDGER (milestone 500). Which default reply shapes this
# session has been shown IN FULL — `core`, `completion`, `asks`, `plan` — one
# key per line in <sid>.shapes.ids under the prior-art directory. The server
# sends a shape in full when its key is absent and as a one-line reminder when
# present, so the core arrives whole once and is a pointer on later turns.
#
# Swept with every other `.ids` ledger on compact and clear, and that is the
# point rather than a side effect: after a compaction the session no longer
# holds the shape, so the next turn gets it in full again. Not aged — the
# per-turn reminder is what keeps it in view between compactions.
# scribe_shapes_file <sid> the ledger's path
# scribe_shapes_seen <file> its keys, comma-joined, for `shapes_seen=`
# scribe_shapes_append <file> keys on stdin, appended
scribe_shapes_file() {
printf '%s/scribe-priorart/%s.shapes.ids' "${TMPDIR:-/tmp}" "$1"
}
scribe_shapes_seen() {
[ -n "$1" ] && [ -f "$1" ] || return 0
awk '/^[a-z]+$/ && !seen[$0]++ { out = out (out == "" ? "" : ",") $0 } END { print out }' \
"$1" 2>/dev/null || true
}
scribe_shapes_append() {
[ -n "$1" ] || { cat >/dev/null; return 0; }
mkdir -p "$(dirname "$1")" 2>/dev/null || true
awk '/^[a-z]+$/' >> "$1" 2>/dev/null || true
}
scribe_clear_session_ledgers() {
# SPARES `*.keep.ids`, which are evidence rather than exclusions — see
# `scribe_rules_append`. Everything else still goes: the convention is
+6
View File
@@ -122,6 +122,11 @@ ledger_q=""
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}"
ledger_q="${ledger_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
# The reply-shape ledger (milestone 500): a shape this session already holds in
# full comes back as its reminder.
shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile")
[ -n "$shapes_seen" ] && ledger_q="${ledger_q}&shapes_seen=${shapes_seen}"
# The event whole, since which field a mapping matches is the mapping's
# business. A write of a very large file is reduced to its name: the moments a
@@ -141,6 +146,7 @@ body=$(printf '%s' "$event" | curl -fsS --max-time 3 \
body_flat=$(printf '%s' "$body" | scribe_json_flat)
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
scribe_json_list "$body_flat" '.shape_keys' | scribe_shapes_append "$shapefile"
context=$(scribe_json_pick "$body_flat" '.context')
[ -n "$context" ] || exit 0
+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)
+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",),
}