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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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).",
|
"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": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|||||||
mkdir -p "$rule_state_dir" 2>/dev/null || true
|
mkdir -p "$rule_state_dir" 2>/dev/null || true
|
||||||
idfile=""
|
idfile=""
|
||||||
rulefile=""
|
rulefile=""
|
||||||
|
shapefile=""
|
||||||
exclude_q=""
|
exclude_q=""
|
||||||
if [ -n "$session_id" ]; then
|
if [ -n "$session_id" ]; then
|
||||||
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
|
# 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}"
|
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
|
||||||
# What the session actually OPENED, as against what it was shown (#4100).
|
# 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")"
|
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
|
fi
|
||||||
|
|
||||||
body=$(curl -fsS --max-time 5 \
|
body=$(curl -fsS --max-time 5 \
|
||||||
@@ -150,6 +156,9 @@ if [ -n "$rulefile" ]; then
|
|||||||
scribe_json_list "$body_flat" '.rule_ids' \
|
scribe_json_list "$body_flat" '.rule_ids' \
|
||||||
| scribe_rules_append "$rulefile"
|
| scribe_rules_append "$rulefile"
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$shapefile" ]; then
|
||||||
|
scribe_json_list "$body_flat" '.shape_keys' | scribe_shapes_append "$shapefile"
|
||||||
|
fi
|
||||||
|
|
||||||
scribe_json_out UserPromptSubmit "$context"
|
scribe_json_out UserPromptSubmit "$context"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1208,6 +1208,35 @@ scribe_written_append() {
|
|||||||
return 0
|
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() {
|
scribe_clear_session_ledgers() {
|
||||||
# SPARES `*.keep.ids`, which are evidence rather than exclusions — see
|
# SPARES `*.keep.ids`, which are evidence rather than exclusions — see
|
||||||
# `scribe_rules_append`. Everything else still goes: the convention is
|
# `scribe_rules_append`. Everything else still goes: the convention is
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ ledger_q=""
|
|||||||
rule_seen=$(scribe_rules_live "$rulefile")
|
rule_seen=$(scribe_rules_live "$rulefile")
|
||||||
[ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}"
|
[ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}"
|
||||||
ledger_q="${ledger_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
|
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
|
# 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
|
# 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)
|
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
||||||
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
|
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')
|
context=$(scribe_json_pick "$body_flat" '.context')
|
||||||
[ -n "$context" ] || exit 0
|
[ -n "$context" ] || exit 0
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from scribe.services import milestones as milestones_svc
|
|||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import platforms as platforms_svc
|
from scribe.services import platforms as platforms_svc
|
||||||
from scribe.services import projects as projects_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 rulebooks as rulebooks_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
from scribe.services import trash as trash_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.
|
project_id: The project to enter.
|
||||||
|
|
||||||
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
|
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,
|
plus unplanned_milestones, milestone_summary_omitted,
|
||||||
unplanned_milestones_omitted, family, inception and systems_bootstrap,
|
unplanned_milestones_omitted, family, inception and systems_bootstrap,
|
||||||
each present only when it applies (see below).
|
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
|
`project` is id, title, status and the full goal. get_project has the
|
||||||
whole record.
|
whole record.
|
||||||
|
|
||||||
@@ -323,6 +329,11 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
out["family"] = family
|
out["family"] = family
|
||||||
if inception_ask:
|
if inception_ask:
|
||||||
out["inception"] = 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -416,8 +416,9 @@ async def update_task(
|
|||||||
reconstructing them, because a remembered milestone title or "next step"
|
reconstructing them, because a remembered milestone title or "next step"
|
||||||
reads exactly like a real one when it is wrong.
|
reads exactly like a real one when it is wrong.
|
||||||
|
|
||||||
Closing a task (done or cancelled) also returns `report_back`: a one-line
|
Closing a task (done or cancelled) also returns `reply_shape`, the
|
||||||
reminder of what the reply to the operator should cover. When 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
|
operator has preferences for how a completion report is written, they
|
||||||
come back as `reply_preferences` ({id, title, statement, kind}) — found
|
come back as `reply_preferences` ({id, title, statement, kind}) — found
|
||||||
by their `when_to_apply`, so a preference whose trigger is writing the
|
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 lesson_rules as lesson_rules_svc
|
||||||
from scribe.services import moment_delivery as moment_delivery_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 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 repo_bindings as repo_bindings_svc
|
||||||
from scribe.services import report_check as report_check_svc
|
from scribe.services import report_check as report_check_svc
|
||||||
from scribe.services import rule_moment_judgments as judgments_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 —
|
different things about what the reader holds —
|
||||||
so they get different lines (#4100). Shares the
|
so they get different lines (#4100). Shares the
|
||||||
ledger directory and the same clear-on-compact.
|
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
|
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
|
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 [],
|
g.user.id, result.get("lesson_ids") or [], rules.get("shown_rule_ids") or [],
|
||||||
arm="p", situation=q, project_id=project_id or None,
|
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["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)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -275,12 +297,16 @@ async def moment():
|
|||||||
`rule_ids` (the FRESH ones, for the hook to append to the ledger) and
|
`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
|
`moments` (the names reached). A lookup, not a ranked search: no
|
||||||
retrieval_logs row; each fresh rule is recorded surfaced with its moment.
|
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 {}
|
event = await request.get_json(silent=True) or {}
|
||||||
tool = str(event.get("tool_name") or "").strip()
|
tool = str(event.get("tool_name") or "").strip()
|
||||||
tool_input = event.get("tool_input")
|
tool_input = event.get("tool_input")
|
||||||
if not tool:
|
if not tool:
|
||||||
return jsonify({"context": "", "rule_ids": [], "moments": []})
|
return jsonify({"context": "", "rule_ids": [], "moments": [], "shape_keys": []})
|
||||||
project_id, _repo, _unbound = await _project_scope()
|
project_id, _repo, _unbound = await _project_scope()
|
||||||
reached, result = await moment_delivery_svc.deliver_for_act(
|
reached, result = await moment_delivery_svc.deliver_for_act(
|
||||||
g.user.id, tool, tool_input if isinstance(tool_input, dict) else {},
|
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"))),
|
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
|
||||||
held=frozenset(_int_list(request.args.get("held_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({
|
return jsonify({
|
||||||
"context": "\n".join(result.lines),
|
"context": context,
|
||||||
"rule_ids": result.rule_ids,
|
"rule_ids": result.rule_ids,
|
||||||
"moments": [hit["moment"] for hit in reached],
|
"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
|
import logging
|
||||||
|
|
||||||
from scribe.services import moment_actions
|
from scribe.services import moment_actions, reply_shapes
|
||||||
from scribe.services import retrieval_pipeline as rp
|
from scribe.services import retrieval_pipeline as rp
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
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
|
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
|
an install that has mounted nothing — never leave the machine. An action
|
||||||
counts only when its moment carries a mount. The skill loader counts
|
counts when its moment carries a mount or a default reply shape. The
|
||||||
whenever anything is mounted: a stored process declares its own moments,
|
skill loader counts whenever anything is mounted: a stored process
|
||||||
which only the load itself can resolve, and a load is rare enough that
|
declares its own moments, which only the load itself can resolve, and a
|
||||||
asking costs nothing.
|
load is rare enough that asking costs nothing.
|
||||||
"""
|
"""
|
||||||
from scribe.services import rulebooks
|
from scribe.services import rulebooks
|
||||||
|
|
||||||
mounted = await rulebooks.mounted_moments(user_id)
|
# A moment that carries a default reply shape (milestone 500) is worth a
|
||||||
if not mounted:
|
# request whether or not anything is mounted on it: the shape is product,
|
||||||
return []
|
# 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)
|
mappings = await moment_actions.list_mappings(user_id)
|
||||||
keys = {
|
keys = {
|
||||||
moment_actions.tool_key(action.tool)
|
moment_actions.tool_key(action.tool)
|
||||||
for action, _via in moment_actions.effective_actions(mappings)
|
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)
|
return sorted(keys)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +90,58 @@ async def deliver_for_act(
|
|||||||
return reached, result
|
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(
|
async def attach_moment_rules(
|
||||||
user_id: int, tool: str, arguments: dict | None, data: dict,
|
user_id: int, tool: str, arguments: dict | None, data: dict,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -114,6 +170,13 @@ async def attach_moment_rules(
|
|||||||
"rule_ids": result.shown_rule_ids,
|
"rule_ids": result.shown_rule_ids,
|
||||||
"open_with": "get_rule(id)",
|
"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
|
except Exception: # noqa: BLE001 - a decoration never breaks the payload
|
||||||
logger.debug("moment rules for %s could not be attached", tool, exc_info=True)
|
logger.debug("moment rules for %s could not be attached", tool, exc_info=True)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -43,10 +43,14 @@ asserted; a sentence added to it should replace one.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from scribe.services import moments
|
from scribe.services import moments
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ReplyShape:
|
class ReplyShape:
|
||||||
@@ -68,6 +72,10 @@ class ReplyShape:
|
|||||||
text: str
|
text: str
|
||||||
"""The shape itself, as the agent reads it."""
|
"""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"
|
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 (
|
SHAPES: dict[str, ReplyShape] = {s.key: s for s in (
|
||||||
ReplyShape(CORE_KEY, "Every reply", "reply.report",
|
ReplyShape(CORE_KEY, "Every reply", "reply.report",
|
||||||
"every turn — in full once per session and after a compaction, "
|
"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",
|
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",
|
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",
|
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),
|
"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)
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ def _no_supersession():
|
|||||||
yield
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_system_labels():
|
def _no_system_labels():
|
||||||
"""Stub the menu's "which System is each line about?" lookup (#4364).
|
"""Stub the menu's "which System is each line about?" lookup (#4364).
|
||||||
|
|||||||
+130
-14
@@ -185,18 +185,23 @@ async def _reachable(mounted, mappings=()):
|
|||||||
return await md.reachable_tools(1)
|
return await md.reachable_tools(1)
|
||||||
|
|
||||||
|
|
||||||
async def test_an_install_with_nothing_mounted_keeps_the_hook_off_the_wire():
|
# The tools the shipped defaults map onto a moment that carries a default reply
|
||||||
listing = AsyncMock()
|
# shape (milestone 500): closing work, a structured question, opening a plan.
|
||||||
with patch.object(rulebooks, "mounted_moments", AsyncMock(return_value=set())), \
|
SHAPED = ["askuserquestion", "create_milestone", "enterplanmode", "exitplanmode",
|
||||||
patch.object(moment_actions, "list_mappings", listing):
|
"skill", "start_planning", "update_milestone", "update_task"]
|
||||||
assert await md.reachable_tools(1) == []
|
|
||||||
listing.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_only_the_tools_whose_moments_carry_a_mount_are_listed():
|
async def test_an_install_with_nothing_mounted_still_asks_about_the_shaped_moments():
|
||||||
assert await _reachable({"work.deliver"}) == ["bash", "skill"]
|
"""The reply shapes are product, so every install has them: the hook asks
|
||||||
assert await _reachable({"work.finish"}) == ["skill", "update_milestone", "update_task"]
|
about the acts that bring one, and about nothing else."""
|
||||||
assert await _reachable({"skill.release"}) == ["skill"]
|
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():
|
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):
|
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
|
||||||
resp = await routes.moment.__wrapped__()
|
resp = await routes.moment.__wrapped__()
|
||||||
body = await resp.get_json()
|
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
|
args, kw = deliver.await_args
|
||||||
assert args == (7, "Bash", {"command": "git push"})
|
assert args == (7, "Bash", {"command": "git push"})
|
||||||
assert kw == {"project_id": 3, "exclude": frozenset({9}), "held": frozenset({4})}
|
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={}):
|
async with app.test_request_context("/api/plugin/moment", method="POST", json={}):
|
||||||
g.user = SimpleNamespace(id=7)
|
g.user = SimpleNamespace(id=7)
|
||||||
resp = await routes.moment.__wrapped__()
|
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():
|
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
|
assert "scribe_rules_live" in src and "scribe_rules_append" in src
|
||||||
# The SHARED ledger, not one of its own.
|
# The SHARED ledger, not one of its own.
|
||||||
assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src and ".rules.ids" in src
|
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 f"'{field}'" in src
|
||||||
|
assert "shapes_seen" in src and "scribe_shapes_append" in src
|
||||||
|
|
||||||
from scribe.routes import plugin as routes
|
from scribe.routes import plugin as routes
|
||||||
|
|
||||||
route = inspect.getsource(routes.moment)
|
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
|
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"attach its mounted rules — a client without the plugin would never "
|
||||||
f"receive them"
|
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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,6 +10,9 @@ import pytest
|
|||||||
from scribe.services import moments, reply_shapes
|
from scribe.services import moments, reply_shapes
|
||||||
from tests.helpers import FakeMCP, dev_only_hits
|
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():
|
def test_there_is_a_core_and_at_least_one_slice():
|
||||||
"""The sweeps below are vacuous over an empty catalog (rule 167)."""
|
"""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)
|
g.user = SimpleNamespace(id=7)
|
||||||
resp = await routes.reply_shapes_route.__wrapped__()
|
resp = await routes.reply_shapes_route.__wrapped__()
|
||||||
assert await resp.get_json() == reply_shapes.catalog()
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ DEFS = HOOKS / "scribe_defs.sh"
|
|||||||
# the convention tests below are what keep this roster honest as it grows.
|
# the convention tests below are what keep this roster honest as it grows.
|
||||||
LEDGERS = {
|
LEDGERS = {
|
||||||
"scribe-priorart": (".ids", ".rules.ids", ".opened.ids", ".sync.ids",
|
"scribe-priorart": (".ids", ".rules.ids", ".opened.ids", ".sync.ids",
|
||||||
".derive.ids"),
|
".derive.ids", ".shapes.ids"),
|
||||||
"scribe-autoinject": (".ids",),
|
"scribe-autoinject": (".ids",),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user