From eadb08c34739b0dd491715a3ad5978f7aa786174 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 9 Oct 2026 14:45:25 -0400 Subject: [PATCH] feat(500): reply shapes are delivered - the core every turn through the ledger, each slice at its moment, reply mounts before the reply (#5495) - 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 .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 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_autoinject.sh | 9 ++ plugin/hooks/scribe_defs.sh | 29 +++++ plugin/hooks/scribe_moment.sh | 6 ++ src/scribe/mcp/tools/projects.py | 13 ++- src/scribe/mcp/tools/tasks.py | 5 +- src/scribe/routes/plugin.py | 40 ++++++- src/scribe/services/moment_delivery.py | 83 ++++++++++++-- src/scribe/services/reply_shapes.py | 89 ++++++++++++++- tests/conftest.py | 14 +++ tests/test_moment_delivery.py | 144 ++++++++++++++++++++++--- tests/test_reply_shape_turn.py | 129 ++++++++++++++++++++++ tests/test_reply_shapes.py | 92 ++++++++++++++++ tests/test_session_ledger_clear.py | 2 +- 14 files changed, 620 insertions(+), 37 deletions(-) create mode 100644 tests/test_reply_shape_turn.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 11304b23..fe082589 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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" }, diff --git a/plugin/hooks/scribe_autoinject.sh b/plugin/hooks/scribe_autoinject.sh index c560aa3d..61a871fe 100755 --- a/plugin/hooks/scribe_autoinject.sh +++ b/plugin/hooks/scribe_autoinject.sh @@ -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 diff --git a/plugin/hooks/scribe_defs.sh b/plugin/hooks/scribe_defs.sh index ee4cd102..9019adb1 100644 --- a/plugin/hooks/scribe_defs.sh +++ b/plugin/hooks/scribe_defs.sh @@ -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 .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 the ledger's path +# scribe_shapes_seen its keys, comma-joined, for `shapes_seen=` +# scribe_shapes_append 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 diff --git a/plugin/hooks/scribe_moment.sh b/plugin/hooks/scribe_moment.sh index 0568fad4..da99d0d3 100644 --- a/plugin/hooks/scribe_moment.sh +++ b/plugin/hooks/scribe_moment.sh @@ -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 diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index a06e72de..fe369c35 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -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 diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 3b4851e6..de960daf 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -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 diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index dffa0f53..5777f097 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -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], }) diff --git a/src/scribe/services/moment_delivery.py b/src/scribe/services/moment_delivery.py index 66f1c1ad..9402e78c 100644 --- a/src/scribe/services/moment_delivery.py +++ b/src/scribe/services/moment_delivery.py @@ -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 diff --git a/src/scribe/services/reply_shapes.py b/src/scribe/services/reply_shapes.py index 4d231702..071d37c1 100644 --- a/src/scribe/services/reply_shapes.py +++ b/src/scribe/services/reply_shapes.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index feb7026d..a94ca320 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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). diff --git a/tests/test_moment_delivery.py b/tests/test_moment_delivery.py index 9242c48a..981bb1d7 100644 --- a/tests/test_moment_delivery.py +++ b/tests/test_moment_delivery.py @@ -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"] + diff --git a/tests/test_reply_shape_turn.py b/tests/test_reply_shape_turn.py new file mode 100644 index 00000000..5575130d --- /dev/null +++ b/tests/test_reply_shape_turn.py @@ -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 diff --git a/tests/test_reply_shapes.py b/tests/test_reply_shapes.py index 60267384..a470b058 100644 --- a/tests/test_reply_shapes.py +++ b/tests/test_reply_shapes.py @@ -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") + diff --git a/tests/test_session_ledger_clear.py b/tests/test_session_ledger_clear.py index 7bbba520..93524867 100644 --- a/tests/test_session_ledger_clear.py +++ b/tests/test_session_ledger_clear.py @@ -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",), }