Reply shapes ride the moments (milestone 500, steps 1–5) #209
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1208,6 +1208,35 @@ scribe_written_append() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# THE REPLY-SHAPE LEDGER (milestone 500). Which default reply shapes this
|
||||
# session has been shown IN FULL — `core`, `completion`, `asks`, `plan` — one
|
||||
# key per line in <sid>.shapes.ids under the prior-art directory. The server
|
||||
# sends a shape in full when its key is absent and as a one-line reminder when
|
||||
# present, so the core arrives whole once and is a pointer on later turns.
|
||||
#
|
||||
# Swept with every other `.ids` ledger on compact and clear, and that is the
|
||||
# point rather than a side effect: after a compaction the session no longer
|
||||
# holds the shape, so the next turn gets it in full again. Not aged — the
|
||||
# per-turn reminder is what keeps it in view between compactions.
|
||||
# scribe_shapes_file <sid> the ledger's path
|
||||
# scribe_shapes_seen <file> its keys, comma-joined, for `shapes_seen=`
|
||||
# scribe_shapes_append <file> keys on stdin, appended
|
||||
scribe_shapes_file() {
|
||||
printf '%s/scribe-priorart/%s.shapes.ids' "${TMPDIR:-/tmp}" "$1"
|
||||
}
|
||||
|
||||
scribe_shapes_seen() {
|
||||
[ -n "$1" ] && [ -f "$1" ] || return 0
|
||||
awk '/^[a-z]+$/ && !seen[$0]++ { out = out (out == "" ? "" : ",") $0 } END { print out }' \
|
||||
"$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
scribe_shapes_append() {
|
||||
[ -n "$1" ] || { cat >/dev/null; return 0; }
|
||||
mkdir -p "$(dirname "$1")" 2>/dev/null || true
|
||||
awk '/^[a-z]+$/' >> "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
scribe_clear_session_ledgers() {
|
||||
# SPARES `*.keep.ids`, which are evidence rather than exclusions — see
|
||||
# `scribe_rules_append`. Everything else still goes: the convention is
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@ from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import platforms as platforms_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
from scribe.services import reply_shapes as reply_shapes_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
@@ -73,11 +74,16 @@ async def enter_project(project_id: int) -> dict:
|
||||
project_id: The project to enter.
|
||||
|
||||
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
|
||||
design_system, project_rules, pattern_coverage —
|
||||
design_system, project_rules, pattern_coverage, reply_shape —
|
||||
plus unplanned_milestones, milestone_summary_omitted,
|
||||
unplanned_milestones_omitted, family, inception and systems_bootstrap,
|
||||
each present only when it applies (see below).
|
||||
|
||||
`reply_shape` is the default shape of every reply you write to the
|
||||
operator: conclusion first, the shortest reply that carries the answer,
|
||||
where the work stands, and the one thing they need to do. Write to it;
|
||||
an operator preference about replies that differs from it wins.
|
||||
|
||||
`project` is id, title, status and the full goal. get_project has the
|
||||
whole record.
|
||||
|
||||
@@ -323,6 +329,11 @@ async def enter_project(project_id: int) -> dict:
|
||||
out["family"] = family
|
||||
if inception_ask:
|
||||
out["inception"] = inception_ask
|
||||
# The core reply shape (milestone 500): for a client with no prompt hook,
|
||||
# entering the project is the one point every session passes before it
|
||||
# writes a reply. A plugin session gets it with its first turn as well;
|
||||
# one repeat per session is the price of reaching every client.
|
||||
out["reply_shape"] = reply_shapes_svc.render(reply_shapes_svc.core(), full=True)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -416,8 +416,9 @@ async def update_task(
|
||||
reconstructing them, because a remembered milestone title or "next step"
|
||||
reads exactly like a real one when it is wrong.
|
||||
|
||||
Closing a task (done or cancelled) also returns `report_back`: a one-line
|
||||
reminder of what the reply to the operator should cover. When the
|
||||
Closing a task (done or cancelled) also returns `reply_shape`, the
|
||||
default shape of the completion report you are about to write, and
|
||||
`report_back`: a one-line reminder of what that reply should cover. When the
|
||||
operator has preferences for how a completion report is written, they
|
||||
come back as `reply_preferences` ({id, title, statement, kind}) — found
|
||||
by their `when_to_apply`, so a preference whose trigger is writing the
|
||||
|
||||
@@ -15,6 +15,7 @@ from scribe.config import Config
|
||||
from scribe.services import lesson_rules as lesson_rules_svc
|
||||
from scribe.services import moment_delivery as moment_delivery_svc
|
||||
from scribe.services import plugin_context as plugin_ctx_svc
|
||||
from scribe.services import reply_shapes as reply_shapes_svc
|
||||
from scribe.services import repo_bindings as repo_bindings_svc
|
||||
from scribe.services import report_check as report_check_svc
|
||||
from scribe.services import rule_moment_judgments as judgments_svc
|
||||
@@ -126,6 +127,17 @@ async def autoinject_retrieve():
|
||||
different things about what the reader holds —
|
||||
so they get different lines (#4100). Shares the
|
||||
ledger directory and the same clear-on-compact.
|
||||
shapes_seen (opt) — comma-separated reply-shape keys this session was
|
||||
already shown in full (milestone 500). The core
|
||||
goes out in full when `core` is absent, as its
|
||||
one-line reminder when present; the hook's
|
||||
ledger is swept on compaction, which is how the
|
||||
full core comes back after one.
|
||||
|
||||
THE TURN'S REPLY SHAPE COMES FIRST (milestone 500 step 3). Every turn ends
|
||||
in a reply and this is the last point before it is written, so the core
|
||||
shape and whatever is mounted on `reply.report` lead the payload. Fresh
|
||||
shape keys come back as `shape_keys` for the ledger.
|
||||
|
||||
TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query
|
||||
but nothing else: the notes menu can be disabled, thresholded and top-k'd
|
||||
@@ -159,9 +171,19 @@ async def autoinject_retrieve():
|
||||
g.user.id, result.get("lesson_ids") or [], rules.get("shown_rule_ids") or [],
|
||||
arm="p", situation=q, project_id=project_id or None,
|
||||
)
|
||||
blocks = [b for b in (rules["context"], result["context"], proposal) if b]
|
||||
# The reply mounts go through the same rule ledger; a rule the prompt arm
|
||||
# just named is not quoted a second time by the turn's delivery.
|
||||
turn = await moment_delivery_svc.deliver_for_turn(
|
||||
g.user.id, seen=reply_shapes_svc.parse_seen(request.args.get("shapes_seen")),
|
||||
project_id=project_id,
|
||||
exclude=frozenset(exclude_rule_ids) | frozenset(rules["rule_ids"]),
|
||||
held=frozenset(held_rule_ids),
|
||||
)
|
||||
blocks = [b for b in (turn["context"], rules["context"], result["context"], proposal) if b]
|
||||
result["context"] = "\n\n".join(blocks)
|
||||
result["rule_ids"] = rules["rule_ids"]
|
||||
result["rule_ids"] = list(dict.fromkeys([*rules["rule_ids"], *turn["rule_ids"]]))
|
||||
result["shape_keys"] = [k for k, form in turn["shape_forms"].items()
|
||||
if form == reply_shapes_svc.FULL]
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -275,12 +297,16 @@ async def moment():
|
||||
`rule_ids` (the FRESH ones, for the hook to append to the ledger) and
|
||||
`moments` (the names reached). A lookup, not a ranked search: no
|
||||
retrieval_logs row; each fresh rule is recorded surfaced with its moment.
|
||||
|
||||
A moment that carries a default reply shape (milestone 500) brings it too,
|
||||
ahead of the rules: in full unless `shapes_seen` names it, then as its
|
||||
reminder. `shape_keys` are the ones sent in full, for the hook's ledger.
|
||||
"""
|
||||
event = await request.get_json(silent=True) or {}
|
||||
tool = str(event.get("tool_name") or "").strip()
|
||||
tool_input = event.get("tool_input")
|
||||
if not tool:
|
||||
return jsonify({"context": "", "rule_ids": [], "moments": []})
|
||||
return jsonify({"context": "", "rule_ids": [], "moments": [], "shape_keys": []})
|
||||
project_id, _repo, _unbound = await _project_scope()
|
||||
reached, result = await moment_delivery_svc.deliver_for_act(
|
||||
g.user.id, tool, tool_input if isinstance(tool_input, dict) else {},
|
||||
@@ -288,10 +314,16 @@ async def moment():
|
||||
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
|
||||
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
|
||||
)
|
||||
shapes, forms = moment_delivery_svc.shapes_for_act(
|
||||
reached, reply_shapes_svc.parse_seen(request.args.get("shapes_seen")),
|
||||
)
|
||||
await reply_shapes_svc.record_delivery(g.user.id, forms, via="hook")
|
||||
context = "\n\n".join(b for b in ("\n\n".join(shapes), "\n".join(result.lines)) if b)
|
||||
return jsonify({
|
||||
"context": "\n".join(result.lines),
|
||||
"context": context,
|
||||
"rule_ids": result.rule_ids,
|
||||
"moments": [hit["moment"] for hit in reached],
|
||||
"shape_keys": [k for k, form in forms.items() if form == reply_shapes_svc.FULL],
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from scribe.services import moment_actions
|
||||
from scribe.services import moment_actions, reply_shapes
|
||||
from scribe.services import retrieval_pipeline as rp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,23 +38,27 @@ async def reachable_tools(user_id: int) -> list[str]:
|
||||
What the plugin's catch-all hook reads once per session window, so the
|
||||
calls that cannot reach a mounted rule — most of them, and every one on
|
||||
an install that has mounted nothing — never leave the machine. An action
|
||||
counts only when its moment carries a mount. The skill loader counts
|
||||
whenever anything is mounted: a stored process declares its own moments,
|
||||
which only the load itself can resolve, and a load is rare enough that
|
||||
asking costs nothing.
|
||||
counts when its moment carries a mount or a default reply shape. The
|
||||
skill loader counts whenever anything is mounted: a stored process
|
||||
declares its own moments, which only the load itself can resolve, and a
|
||||
load is rare enough that asking costs nothing.
|
||||
"""
|
||||
from scribe.services import rulebooks
|
||||
|
||||
mounted = await rulebooks.mounted_moments(user_id)
|
||||
if not mounted:
|
||||
return []
|
||||
# A moment that carries a default reply shape (milestone 500) is worth a
|
||||
# request whether or not anything is mounted on it: the shape is product,
|
||||
# so every install has it.
|
||||
shaped = {s.moment for s in reply_shapes.SHAPES.values() if s.key != reply_shapes.CORE_KEY}
|
||||
mounted = set(await rulebooks.mounted_moments(user_id))
|
||||
wanted = mounted | shaped
|
||||
mappings = await moment_actions.list_mappings(user_id)
|
||||
keys = {
|
||||
moment_actions.tool_key(action.tool)
|
||||
for action, _via in moment_actions.effective_actions(mappings)
|
||||
if action.moment in mounted
|
||||
if action.moment in wanted
|
||||
}
|
||||
keys.add(moment_actions.SKILL_TOOL)
|
||||
if mounted:
|
||||
keys.add(moment_actions.SKILL_TOOL)
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
@@ -86,6 +90,58 @@ async def deliver_for_act(
|
||||
return reached, result
|
||||
|
||||
|
||||
def shapes_for_act(reached: list[dict], seen: frozenset[str] = frozenset()) -> tuple[list[str], dict[str, str]]:
|
||||
"""The default reply shapes riding the moments this act reached (milestone 500).
|
||||
|
||||
Closing a task comes just before a completion report, a structured
|
||||
question is an ask, opening a plan comes before a plan is put up for
|
||||
review — so the shape for that reply arrives at the act, before the reply
|
||||
is written. In full the first time a session meets it, as its reminder
|
||||
after that; a door with no ledger passes `seen` empty.
|
||||
"""
|
||||
return reply_shapes.deliver(
|
||||
reply_shapes.for_moments([hit["moment"] for hit in reached]), seen,
|
||||
)
|
||||
|
||||
|
||||
# What the per-turn delivery says reached `reply.report`: the turn has not
|
||||
# ended yet, but every turn ends in a reply, and this is the last point
|
||||
# before it is written.
|
||||
_REACHED_BY_TURN = "the reply this turn will end with"
|
||||
|
||||
|
||||
async def deliver_for_turn(
|
||||
user_id: int, *, seen: frozenset[str] = frozenset(), project_id: int | None = None,
|
||||
exclude: frozenset[int] = frozenset(), held: frozenset[int] = frozenset(),
|
||||
) -> dict:
|
||||
"""What every turn carries before its reply is written (milestone 500 step 3).
|
||||
|
||||
The core reply shape — in full once per session and again after a
|
||||
compaction (the ledger is swept then), otherwise its one-line reminder —
|
||||
and the rules and preferences mounted on `reply.report`, under the same
|
||||
rule ledger as every other arm. The Stop hook's reply moment fires after
|
||||
the reply exists, which is too late to shape it and is kept as the
|
||||
backstop; this is the point before.
|
||||
|
||||
Returns `{context, rule_ids, shape_forms}`. Fails open to the core alone,
|
||||
and the core itself never fails: it is a constant.
|
||||
"""
|
||||
blocks, forms = reply_shapes.deliver([reply_shapes.core()], seen)
|
||||
rule_ids: list[int] = []
|
||||
try:
|
||||
reached = [{"moment": "reply.report", "tool": "", "match": _REACHED_BY_TURN,
|
||||
"via": moment_actions.DEFAULT}]
|
||||
result = await deliver_moments(
|
||||
user_id, reached, project_id=project_id, exclude=exclude, held=held,
|
||||
)
|
||||
blocks.extend(result.lines)
|
||||
rule_ids = result.rule_ids
|
||||
except Exception: # noqa: BLE001 - the mounted half never costs the core
|
||||
logger.debug("reply.report mounts not delivered for the turn", exc_info=True)
|
||||
await reply_shapes.record_delivery(user_id, forms, via="turn")
|
||||
return {"context": "\n\n".join(blocks), "rule_ids": rule_ids, "shape_forms": forms}
|
||||
|
||||
|
||||
async def attach_moment_rules(
|
||||
user_id: int, tool: str, arguments: dict | None, data: dict,
|
||||
) -> dict:
|
||||
@@ -114,6 +170,13 @@ async def attach_moment_rules(
|
||||
"rule_ids": result.shown_rule_ids,
|
||||
"open_with": "get_rule(id)",
|
||||
}
|
||||
# The reply shape for this moment, in full: no ledger reaches this
|
||||
# door, and an act like closing a task is rare enough that the shape
|
||||
# arriving each time costs less than one report written without it.
|
||||
blocks, forms = shapes_for_act(_reached)
|
||||
if blocks:
|
||||
data["reply_shape"] = "\n\n".join(blocks)
|
||||
await reply_shapes.record_delivery(user_id, forms, via="mcp")
|
||||
except Exception: # noqa: BLE001 - a decoration never breaks the payload
|
||||
logger.debug("moment rules for %s could not be attached", tool, exc_info=True)
|
||||
return data
|
||||
|
||||
@@ -43,10 +43,14 @@ asserted; a sentence added to it should replace one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from scribe.services import moments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplyShape:
|
||||
@@ -68,6 +72,10 @@ class ReplyShape:
|
||||
text: str
|
||||
"""The shape itself, as the agent reads it."""
|
||||
|
||||
reminder: str
|
||||
"""One line standing in for `text` once the session has been shown it:
|
||||
the pointer form, which also serves as the reminder on later turns."""
|
||||
|
||||
|
||||
CORE_KEY = "core"
|
||||
|
||||
@@ -173,13 +181,21 @@ A plan put up for review, before starting:
|
||||
SHAPES: dict[str, ReplyShape] = {s.key: s for s in (
|
||||
ReplyShape(CORE_KEY, "Every reply", "reply.report",
|
||||
"every turn — in full once per session and after a compaction, "
|
||||
"otherwise as a one-line pointer", _CORE),
|
||||
"otherwise as a one-line pointer", _CORE,
|
||||
"conclusion first; the shortest reply that carries the answer; place the "
|
||||
"work; settle what only you can see and ask about direction; end with the "
|
||||
"one thing they need to do, or say there is none."),
|
||||
ReplyShape("completion", "Completion report", "work.finish",
|
||||
"when a piece of work is closed", _COMPLETION),
|
||||
"when a piece of work is closed", _COMPLETION,
|
||||
"where this sits · what now works · how / why, and how it was verified · "
|
||||
"needs you (theirs to decide, and blocking) · next."),
|
||||
ReplyShape("asks", "Asking them to decide or act", "reply.ask",
|
||||
"when a structured question is put to them", _ASKS),
|
||||
"when a structured question is put to them", _ASKS,
|
||||
"the question first and your recommendation first among the options; "
|
||||
"settle what you can see, and ask only about direction."),
|
||||
ReplyShape("plan", "A plan for review", "work.plan",
|
||||
"when a plan is opened", _PLAN),
|
||||
"when a plan is opened", _PLAN,
|
||||
"goal · steps · how you will know it works · open questions."),
|
||||
)}
|
||||
|
||||
|
||||
@@ -205,3 +221,68 @@ def catalog() -> dict:
|
||||
],
|
||||
"total": len(SHAPES),
|
||||
}
|
||||
|
||||
|
||||
# ── Delivery: what a door puts in front of the agent ────────────────────
|
||||
|
||||
# Said once, in the full form: the default is a floor an operator can raise.
|
||||
_FULL_HEAD = ("Reply shape · {title} — the default shape for {what}. Where an operator "
|
||||
"preference shown beside it differs, the preference is what they asked for.")
|
||||
_POINTER = ("Reply shape · {title}, shown in full earlier this session "
|
||||
"(list_reply_shapes has it): {reminder}")
|
||||
|
||||
FULL = "full"
|
||||
POINTER = "pointer"
|
||||
|
||||
|
||||
def render(shape: ReplyShape, *, full: bool) -> str:
|
||||
"""The shape as a door delivers it: in full the first time a session sees
|
||||
it, as its one-line reminder after that."""
|
||||
if not full:
|
||||
return _POINTER.format(title=shape.title, reminder=shape.reminder)
|
||||
what = moments.MOMENTS[shape.moment].means
|
||||
return _FULL_HEAD.format(title=shape.title, what=what) + "\n\n" + shape.text
|
||||
|
||||
|
||||
def deliver(shapes: list[ReplyShape], seen: set[str] | frozenset[str]) -> tuple[list[str], dict[str, str]]:
|
||||
"""Render these shapes against what the session was already shown.
|
||||
|
||||
Returns the blocks and `{key: form}` — every shape delivered and whether
|
||||
it went out in full or as a pointer. The keys sent in full are the ones a
|
||||
door's ledger appends; a door with no ledger passes `seen` empty and
|
||||
every shape goes out in full.
|
||||
"""
|
||||
blocks, forms = [], {}
|
||||
for shape in shapes:
|
||||
full = shape.key not in seen
|
||||
blocks.append(render(shape, full=full))
|
||||
forms[shape.key] = FULL if full else POINTER
|
||||
return blocks, forms
|
||||
|
||||
|
||||
def parse_seen(raw: str | None) -> frozenset[str]:
|
||||
"""A ledger's comma-separated keys, keeping only shapes that exist."""
|
||||
return frozenset(k for k in (p.strip() for p in (raw or "").split(",")) if k in SHAPES)
|
||||
|
||||
|
||||
async def record_delivery(user_id: int | None, forms: dict[str, str], *, via: str) -> None:
|
||||
"""One `reply_shape` row per delivery: which shapes, in which form, by which
|
||||
door. Fire-and-forget — telemetry never takes a shape away from the reader.
|
||||
|
||||
In AppLog beside 409's `report_check`, so the two readings of one reply —
|
||||
was the shape delivered, did the reply carry it — sit in one place.
|
||||
"""
|
||||
if not forms:
|
||||
return
|
||||
try:
|
||||
from scribe.models import async_session
|
||||
from scribe.models.app_log import AppLog
|
||||
|
||||
async with async_session() as session:
|
||||
session.add(AppLog(
|
||||
category="plugin", user_id=user_id, action="reply_shape",
|
||||
details=json.dumps({"shapes": forms, "via": via}),
|
||||
))
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||
logger.debug("reply shape delivery not recorded", exc_info=True)
|
||||
|
||||
@@ -102,6 +102,20 @@ def _no_supersession():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_reply_shape_log():
|
||||
"""Stub the reply-shape delivery row (milestone 500 step 3).
|
||||
|
||||
Autouse for _no_system_labels' reason: every turn's retrieval, every
|
||||
moment request and every Scribe tool that reaches a shaped moment writes
|
||||
one AppLog row, and those paths are tested without a database.
|
||||
tests/test_reply_shapes.py binds the real function at import time,
|
||||
before this patch runs.
|
||||
"""
|
||||
with patch("scribe.services.reply_shapes.record_delivery", AsyncMock()):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_system_labels():
|
||||
"""Stub the menu's "which System is each line about?" lookup (#4364).
|
||||
|
||||
+130
-14
@@ -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"]
|
||||
|
||||
|
||||
@@ -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 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")
|
||||
|
||||
|
||||
@@ -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",),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user