CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 24s
- api.py's build-version docstring states why a channel name is not a build id, without the dated incident. - rule_version states why rules need a history, without the rule that was rescoped and the date. - Capitalised "Rule N" / "Milestone N" citations the earlier lowercase grep missed, in reply_shapes, supersession, services/family and models/family. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
363 lines
16 KiB
Python
363 lines
16 KiB
Python
"""The default shapes of a reply, and the moment each one rides.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
A reply is where the person the work is for finds out what happened, and they
|
|
were not there while it was done. The shapes make a reply readable to them —
|
|
conclusion first, the shortest reply that carries the answer, where the work
|
|
stands. A skill arrives only when the agent decides to load it, so in a long
|
|
session a shape held in a skill fades out of context exactly when replies get
|
|
longer.
|
|
|
|
So the shapes are product content held here, on the server, and delivered by
|
|
the moments they belong to rather than by the agent remembering to ask:
|
|
|
|
- the CORE applies to every reply, so it rides every turn — in full once per
|
|
session and after a compaction, and as a one-line pointer otherwise;
|
|
- each SLICE belongs to one kind of reply, and arrives at the moment that
|
|
comes just before that kind is written: closing a task comes before a
|
|
completion report, a structured question before an ask, opening a plan
|
|
before a plan is put up for review.
|
|
|
|
The moment already knows which kind of reply is coming, so there is no "pick
|
|
the type, then load its shape" step for an agent to skip.
|
|
|
|
ONE COPY
|
|
|
|
This module is the single source. The `reporting-back` skill keeps the
|
|
long-form reference (a worked example, the reasoning) and points here; the
|
|
hooks carry timing and transport and say nothing of their own about shape. An
|
|
operator's adjustments are `preference` records mounted on the same moments,
|
|
and arrive beside the default — where the two differ, the preference is what
|
|
the operator asked for.
|
|
|
|
HOW TO WRITE ONE
|
|
|
|
Domain-neutral: the work may be software, configuration,
|
|
infrastructure or anything else a person drives through an agent. "Evidence"
|
|
is what the reader could open to check; "verified" is whatever checking means
|
|
for that work. The test beside this module refuses software-only vocabulary.
|
|
|
|
Short, because it is paid for in every session's context. The core's budget is
|
|
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:
|
|
"""One piece of the default reply shape, and when it arrives."""
|
|
|
|
key: str
|
|
"""Stable identifier: what a delivery records and the Settings view keys on."""
|
|
|
|
title: str
|
|
"""What the piece is, as the Settings view names it."""
|
|
|
|
moment: str
|
|
"""The catalog moment this piece rides — where an operator's own
|
|
preferences for it are mounted too."""
|
|
|
|
delivered: str
|
|
"""When it arrives, said plainly for the Settings view."""
|
|
|
|
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"
|
|
|
|
# ~550 tokens at four characters a token. The core is paid for in every
|
|
# session, so this is the ceiling the test holds it to — not a target. The
|
|
# core sits close to it, so a sentence added has to replace one.
|
|
CORE_BUDGET_CHARS = 2200
|
|
# A slice is paid for only at its moment, and once per session; it can afford
|
|
# more than the core and still has to stay a slice, not a whole skill.
|
|
# The asks slice is the largest (~1,530) because it carries who decides what in
|
|
# full: handing a question back is what an ask is tempted to do.
|
|
SLICE_BUDGET_CHARS = 1600
|
|
|
|
|
|
_CORE = """\
|
|
Shape the reply around where the work stands, not the order you did things in. \
|
|
The reader was not there while you worked.
|
|
|
|
- **Conclusion first** — the result, the verdict or the question, then what supports it.
|
|
- **The shortest reply that carries the answer.** Length is work handed back to the \
|
|
reader. It is earned by a comparison they asked for, options that need laying side by \
|
|
side, or numbers that are the point.
|
|
- **One topic per section; bold the few things that matter.**
|
|
- **Plain words** — the reader's vocabulary, not names you coined while working.
|
|
- **Place the work** — the task or plan it belongs to, by id and title, read from the \
|
|
record rather than recalled. Work with no record behind it says so.
|
|
- **Answer the standing questions, even with "nothing"**: does anything need them, and \
|
|
what happens next. A section that explains earns its place only when it changes what \
|
|
they do or decide; the rest belongs in the record's log.
|
|
- **Decide what only you can see; ask about direction.** Whether a finding holds, what a \
|
|
measurement says, whether the work is done: settle it, act, and say why so they can \
|
|
overrule it. Priorities, choices they will live with, and anything hard to undo are theirs.
|
|
- **A decision they have made is the input to the work.** Act on it; reopen it only \
|
|
for new evidence, said once.
|
|
- **Holding an action until they say yes?** Put it near the top, under "Approval requested".
|
|
- **End with the one thing they need to decide or do, in bold** — or say there is none.
|
|
|
|
By kind:
|
|
- **Answer** — the answer, then what they could open to check it. An evaluation is \
|
|
verdict · what exists · the gaps · a recommendation.
|
|
- **Proposal** — two or three approaches, the trade-off of each, one recommendation. \
|
|
A review is findings ranked by how much they matter.
|
|
- **Finding** — what you found, why it happens, and what you decided and did about it.
|
|
- **Progress** — a line or two. **Blocked** — what stopped, what you tried, what you need.
|
|
- **Where are we** — the plan and its progress · done · open · needs you · next.
|
|
|
|
Before sending, read it as someone who was not there, reading quickly; then once more \
|
|
for what can go."""
|
|
|
|
|
|
_COMPLETION = """\
|
|
A completion report, from the `placement` the closing call returned:
|
|
|
|
- **Where this sits** — the plan and the step, or the task alone when it has no plan.
|
|
- **What now works** — outcomes the reader would notice ("you can now…", "X no longer…"), \
|
|
not the steps behind them; those belong in the task's log.
|
|
- **How / why** — only the decisions worth knowing, and how it was verified. Anything \
|
|
that could not be verified is said here rather than left to read as passed.
|
|
- **Needs you** — an action, an approval, a decision, or "nothing". It has to answer two \
|
|
questions yes: is it theirs to decide, and is work waiting on it? A question you could settle by \
|
|
reading or measuring something is work not yet done — settle it, say which way you went, \
|
|
and leave them free to overrule.
|
|
- **Next** — from `placement.next`, or say the plan is finished. An offer to fix \
|
|
something you found goes here."""
|
|
|
|
|
|
_ASKS = """\
|
|
Asking them to decide or to act — pick the shape:
|
|
|
|
- **Decision** — the question first · two to four options, each with what it changes · \
|
|
your recommendation first among them.
|
|
- **Clarification** — "My reading is X · the gap is Y · unless you say otherwise I'll do Z."
|
|
- **Handoff** (only they can do it) — the action · why it needs them · what it unblocks · \
|
|
what you will do after.
|
|
- **Approval** (ready, and holding for a yes) — under "Approval requested": exactly what \
|
|
happens on yes, one numbered item per change so they can approve part · why it needs \
|
|
them · how it is undone.
|
|
- **Conflict** (what you are about to do clashes with a rule, a plan or an earlier \
|
|
decision) — what it says · what you were about to do · where they clash · A or B?
|
|
|
|
**Who decides what.** You decide what only you can see the evidence for: whether a \
|
|
finding holds, what a measurement says, whether a record is right, whether the work is \
|
|
done, and anything you can settle by reading or measuring. Decide, act, and say why, so \
|
|
they can overrule it; "your call" on one of these hands them a question they cannot see \
|
|
into. Evenly balanced evidence is still yours — say which way you went and what would \
|
|
change your mind. They decide direction: what matters most, what the thing should be, a \
|
|
trade-off only they can price, anything they will live with afterwards — and every act \
|
|
that is hard to undo or reaches outside the work.
|
|
|
|
When an option rests on existing behaviour, say whose call that was — theirs (cite it) \
|
|
or a past session's nobody confirmed."""
|
|
|
|
|
|
_PLAN = """\
|
|
A plan put up for review, before starting:
|
|
|
|
- **Goal** — what done looks like, and why, in a sentence or two.
|
|
- **Steps** — in order, each small enough to check on its own.
|
|
- **How you will know it works** — something observable, not "it is written".
|
|
- **Open questions** — only the ones that are theirs to answer."""
|
|
|
|
|
|
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,
|
|
"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,
|
|
"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,
|
|
"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,
|
|
"goal · steps · how you will know it works · open questions."),
|
|
)}
|
|
|
|
|
|
def core() -> ReplyShape:
|
|
return SHAPES[CORE_KEY]
|
|
|
|
|
|
def for_moments(names: list[str]) -> list[ReplyShape]:
|
|
"""The slices that ride these moments, in catalog order. The core is not a
|
|
slice: it rides every turn, whatever moment the turn reaches."""
|
|
wanted = set(names)
|
|
return [s for s in SHAPES.values() if s.key != CORE_KEY and s.moment in wanted]
|
|
|
|
|
|
def catalog() -> dict:
|
|
"""The shapes as plain data, for the MCP tool and the REST door alike."""
|
|
return {
|
|
"shapes": [
|
|
{"key": s.key, "title": s.title, "moment": s.moment,
|
|
"means": moments.MOMENTS[s.moment].means,
|
|
"delivered": s.delivered, "text": s.text}
|
|
for s in SHAPES.values()
|
|
],
|
|
"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)
|
|
|
|
|
|
# ── The operator's view ─────────────────────────────────────────────────
|
|
|
|
# The window the Settings view counts deliveries over — the same 30 days the
|
|
# moments panel beside it reads, so the two count the same stretch of work.
|
|
OVERVIEW_DAYS = 30
|
|
|
|
|
|
async def delivery_counts(user_id: int, *, days: int = OVERVIEW_DAYS) -> dict[str, dict[str, int]]:
|
|
"""How often each shape went out, in each form, over the last `days`.
|
|
|
|
Read from the `reply_shape` rows `record_delivery` writes — one per
|
|
delivery, naming each shape and whether it went whole or as its reminder.
|
|
A row whose details do not parse is skipped rather than guessed at.
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.app_log import AppLog
|
|
|
|
since = datetime.now(timezone.utc) - timedelta(days=days)
|
|
counts: dict[str, dict[str, int]] = {k: {FULL: 0, POINTER: 0} for k in SHAPES}
|
|
async with async_session() as session:
|
|
rows = (await session.execute(
|
|
select(AppLog.details).where(
|
|
AppLog.category == "plugin", AppLog.action == "reply_shape",
|
|
AppLog.user_id == user_id, AppLog.created_at >= since,
|
|
)
|
|
)).scalars().all()
|
|
for raw in rows:
|
|
try:
|
|
forms = json.loads(raw or "{}").get("shapes") or {}
|
|
except (ValueError, AttributeError):
|
|
continue
|
|
for key, form in forms.items() if isinstance(forms, dict) else ():
|
|
if key in counts and form in counts[key]:
|
|
counts[key][form] += 1
|
|
return counts
|
|
|
|
|
|
async def overview(user_id: int, *, days: int = OVERVIEW_DAYS) -> dict:
|
|
"""The Settings view's read: every shape, the operator's preferences that
|
|
adjust it, and how often it was delivered.
|
|
|
|
A preference adjusts a shape by being MOUNTED on the shape's moment — the
|
|
same lookup that delivers it beside the shape — so what this lists is
|
|
exactly what arrives with it. Global homes only: a project's own
|
|
preferences are that project's, and its rules tab lists them.
|
|
|
|
The counts are telemetry and fail open to `deliveries_failed` with "—" in
|
|
the view; the preference lookup is the substance and is allowed to fail
|
|
the request, so the view shows an error instead of "no preferences".
|
|
"""
|
|
from scribe.services import rulebooks
|
|
|
|
data = catalog()
|
|
try:
|
|
counts: dict[str, dict[str, int]] | None = await delivery_counts(user_id, days=days)
|
|
except Exception: # noqa: BLE001 - a missing count is shown as unknown
|
|
logger.warning("reply shape delivery counts failed", exc_info=True)
|
|
counts = None
|
|
for row in data["shapes"]:
|
|
mounted = await rulebooks.rules_on_moments(user_id, [row["moment"]])
|
|
row["preferences"] = [
|
|
{"id": rule.id, "title": rule.title, "statement": rule.statement}
|
|
for rule, _at in mounted if rule.kind == "preference"
|
|
]
|
|
row["deliveries"] = counts.get(row["key"]) if counts is not None else None
|
|
data["days"] = days
|
|
data["deliveries_failed"] = counts is None
|
|
return data
|