docs(6204): shipped instruction text describes the product, not this instance's records
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 39s

MCP tool docstrings and comments, the server's orientation notes, the plugin
skills, every hook script and the plugin README/PACKAGING drop record ids,
milestone numbers, dates and incident stories, and state each non-obvious why
as a present-tense fact. History stays in the task logs.

Corrections found on the way:
- plugin README/PACKAGING said every hook is a GET; moment, rule-opened and
  reply-rules POST, so the plugin's key needs write scope. Both now say so,
  name Settings -> MCP Access, and list every wired hook and endpoint.
- the contract-change hint printed "(lesson #4207)", this instance's record,
  to every install. It no longer cites a record.
- tasks.py claimed there is no delete_task; there is.
- rulebooks.py cited delete_event and a design spec that no longer exist.
- scribe_after_write.sh carried an always-empty etag_q; removed.
- test_write_path_trigger pinned "| tac | scribe_defs | head -1", text that
  existed only in a comment about the old code; it now pins the real tail -1
  pipeline.
- docs/development.md: CI runs on Gitea Actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 19:45:09 -04:00
co-authored by Claude Opus 5.5
parent fc8f554cee
commit 17777e9bb1
43 changed files with 641 additions and 777 deletions
+49 -56
View File
@@ -10,9 +10,8 @@ from quart import Quart
## What this block is — read before editing it
#
# ONE OWNER PER PIECE OF GUIDANCE (decision #4027, milestone 410). Scribe's
# guidance to agents lives where it can be delivered, and each topic is stated
# in full exactly once:
# ONE OWNER PER PIECE OF GUIDANCE. Scribe's guidance to agents lives where it
# can be delivered, and each topic is stated in full exactly once:
# - Tool docstrings: each tool's contract, delivered with its schema.
# - In-band tool responses: behaviour prose cannot be trusted to trigger —
# the duplicate gate, the guessed-id refusal, `systems_hint`,
@@ -25,22 +24,19 @@ from quart import Quart
#
# THIS BLOCK IS THE SERVER'S ORIENTATION: the workflow across tools. It reaches
# every MCP client, so it names no client, and it points at where each practice
# is stated rather than restating it. What belongs here, per the spec and the
# field (spike #4389): how the tools fit together — which to call first, which
# before which. What does not: a tool's own contract (its description carries
# it), and stance — who judges, how to report, what a missed rule means. Those
# fire mid-work, not at the start; the skills state them and in-band responses
# (`placement`, `report_back`) cue them at the moment they apply. A new topic
# gets a line here only if it is a session-start practice.
# is stated rather than restating it. What belongs here, per the MCP spec: how
# the tools fit together — which to call first, which before which. What does
# not: a tool's own contract (its description carries it), and stance — who
# judges, how to report, what a missed rule means. Those fire mid-work, not at
# the start; the skills state them and in-band responses (`placement`,
# `report_back`) cue them at the moment they apply. A new topic gets a line
# here only if it is a session-start practice.
#
# BUDGET: at most 1,600 characters (test_instructions_fit_the_fold). Claude
# Code injects only the first ~2,048 characters of a server's instructions and
# cuts the rest mid-word (#2562, observed live), and other clients differ, so
# nothing load-bearing may sit past the fold; the rest of the field runs
# ~600–1,600 (#4389), and a block pressed against the cap is being used as a
# rulebook. The history of what was traded
# for space before the ownership split (milestones 317, 333, 409) is in
# decision #4027 and the notes it supersedes.
# cuts the rest mid-word, and other clients differ, so nothing load-bearing may
# sit past the fold. A block pressed against the cap is being used as a
# rulebook.
_INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting,
record as you go, keep one copy here, not in local memory. Each
@@ -83,7 +79,7 @@ shared:true records are another user's suggestion, not settled practice.
# the point; staleness is the cost, and test_mcp_auth covers that: EVERY
# registered tool must appear in exactly one of _READ_ONLY_TOOLS, _WRITE_TOOLS or
# _DELIBERATELY_WRITE_SCOPED below, so adding one forces a decision instead of
# silently denying it — whatever the tool is called (#3191).
# silently denying it — whatever the tool is called.
#
# Membership means "reads the operator's data and mutates none of it". Several
# getters record a retrieval event via record_pulled; that is telemetry about
@@ -105,7 +101,7 @@ _READ_ONLY_TOOLS = frozenset({
"find_duplicate_snippets", "find_duplicate_records",
# Snippets and processes are notes with a kind. A key that may read a note
# but not a snippet inverts the sensitivity ordering: it exposes the
# free-text records and withholds the structured ones (#2496).
# free-text records and withholds the structured ones.
"get_snippet", "list_snippets",
"get_process", "list_processes",
"get_lesson", "list_lessons",
@@ -119,58 +115,56 @@ _READ_ONLY_TOOLS = frozenset({
# Which repos map to which project. Read-only by nature; bind_repo /
# unbind_repo are the writes.
"list_repo_bindings",
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
# The shape ledger's todo query. Reads only — classify_shapes is
# the write, and it is deliberately NOT here.
"list_shapes", "shape_history", "stamps_to_review",
# The retrieval telemetry readout (#2975). Aggregates two log tables and
# The retrieval telemetry readout. Aggregates two log tables and
# writes nothing. Listed explicitly because its name carries no read
# prefix, so the completeness test below cannot derive it — the same
# reason `enter_project` is spelled out above.
"retrieval_telemetry",
# The note staleness sweep (milestone 317). A pure read — mark_note_verified
# The note staleness sweep. A pure read — mark_note_verified
# is the write, and it is deliberately NOT here. Spelled out for
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of
# the prefixes the completeness test derives from, so nothing would have
# prompted this decision.
"notes_due_for_verification",
# The review pass's sample (#4772). Re-runs logged queries and writes no
# The review pass's sample. Re-runs logged queries and writes no
# row of any kind — not even telemetry, since nothing is put in front of a
# working session. Spelled out for retrieval_telemetry's reason.
"menus_to_review",
# Its rule twin and a rule's edit history (milestones 312 and 323). Both
# pure reads, and both sat unlisted — so a read key was refused them — for
# the same reason: no read prefix, back when the completeness test only
# looked at names that had one (#3191). rule_history records a pull the way
# the getters above do.
# Its rule twin and a rule's edit history. Both pure reads with no read
# prefix, so spelled out; rule_history records a pull the way the getters
# above do.
"rules_due_for_verification", "rule_history",
# What each retrieval surface's floor and budget currently are, and what
# has been changed about them (#4102). Both pure reads; `tune_retrieval` is
# has been changed about them. Both pure reads; `tune_retrieval` is
# the write and is deliberately NOT here. Read access matters more than
# usual for these two: a session that cannot see the bar in force, or the
# reason it was last moved, is a session that will move it again blind.
"retrieval_surfaces", "retrieval_tuning_history",
# The wide net (#4103) — ranked rule candidates with no bar, for the
# The wide net — ranked rule candidates with no bar, for the
# moment before a consequential act. A pure read, and one a read key needs
# most: it is the surface the "ask before acting" reflex calls, and a key
# that could not reach it would be denied exactly the check it should run.
"what_might_apply",
# The moment catalog (milestone 458). A pure read of a constant; the
# The moment catalog. A pure read of a constant; the
# mount and mapping writes that use it are separate tools. Spelled out for
# retrieval_telemetry's reason, and needed by a read key so that a line
# naming a moment can be understood by whoever was shown it.
"list_moments",
# The default reply shapes (milestone 500). A pure read of a constant, and
# The default reply shapes. A pure read of a constant, and
# one a read key needs: a session shown a shape may want the rest.
"list_reply_shapes",
# The platform catalog and a project's answers (milestone 463). A pure
# The platform catalog and a project's answers. A pure
# read; set_project_platforms is the write.
"list_platforms",
# Family canon (milestone 463): ideas, one idea with its precedents, the
# Family canon: ideas, one idea with its precedents, the
# decision log, and the adoption ledger. Reads of records the caller can
# read.
"list_family_ideas", "get_family_idea", "list_family_decisions",
"get_family_adoption", "list_family_adoptions",
# The pass over the corpus and its queue (milestone 458 step 7): which
# The pass over the corpus and its queue: which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
"rules_to_mount", "rule_moment_proposals",
@@ -180,8 +174,8 @@ _READ_ONLY_TOOLS = frozenset({
# absent from _READ_ONLY_TOOLS is already denied to a read key. It exists so the
# classification is total: test_mcp_auth requires every registered tool to sit
# in exactly one of the three sets, which is what makes forgetting impossible
# rather than merely unlikely. Before #3191 the test only asked about tools whose
# names looked like reads, and two reads with other names were denied for weeks.
# rather than merely unlikely. A test that only asked about read-looking names
# would miss a read named any other way.
_WRITE_TOOLS = frozenset({
# notes, tasks, planning
"create_note", "update_note", "delete_note",
@@ -223,22 +217,22 @@ _WRITE_TOOLS = frozenset({
# reads that happen to leave a trace, while this tool's entire effect is
# the row — and the row carries `detail`, free prose the agent authored.
# A read-scoped key that can put text into the operator's database is not
# read-scoped, whatever table it lands in (#4212).
# read-scoped, whatever table it lands in.
"rule_outcome",
# retrieval tuning — a write in both senses: it moves the number the arm
# reads, and it appends the reason to the audit trail (#4102).
# reads, and it appends the reason to the audit trail.
"tune_retrieval",
"migrate_retrieval_floor",
# Which actions reach which moment on this install (milestone 458). Each
# Which actions reach which moment on this install. Each
# changes what fires for every later session, so a read key is refused.
"map_action", "unmap_action",
# Proposing moments for a rule writes a judgment row; judging one mounts
# or unmounts the rule (step 7).
# or unmounts the rule.
"propose_rule_moments", "judge_rule_moments",
# A misfire report writes a counted row that can become an unmount
# proposal (step 7b).
# proposal.
"rule_misfired",
# A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
# A reviewer's verdicts on logged menu lines — rows carrying free
# prose the agent authored, `rule_outcome`'s reason for being a write.
"judge_menu",
# trash
@@ -309,12 +303,11 @@ class StrictArgsMCPServer(MCPServer):
default. On the create/update tools the default is "", which turns a
plausible near-miss (`content=` for `body=`, primed by add_task_log's
`content`) into SILENT DATA LOSS: the call reports success and stores an
empty body, leaving a record search cannot see (#2709). Two notes were
persisted body-less that way before anyone noticed.
empty body, leaving a record search cannot see.
An error the caller sees once is strictly better than data half-written
forever, so the policy is applied to every tool, not just the two that
bit: nothing here knows tool semantics, only that an argument nobody
forever, so the policy is applied to every tool: nothing here knows tool
semantics, only that an argument nobody
declared cannot have been meant to be dropped.
"""
@@ -344,11 +337,11 @@ class StrictArgsMCPServer(MCPServer):
try:
return await super().call_tool(name, arguments, context)
except UnexpectedToolError as exc:
# ValueError is how every Scribe tool refuses (#4794): what was
# refused, why, and what to do instead, written for the agent.
# SDK 2.x masks it like a crash, so the agent got a failure with
# no reason and could only retry blind. Other exceptions ARE
# crashes; their text can carry internals and stays masked.
# ValueError is how every Scribe tool refuses: what was refused,
# why, and what to do instead, written for the agent. SDK 2.x
# masks it like a crash, which would leave the agent retrying
# blind. Other exceptions ARE crashes; their text can carry
# internals and stays masked.
if isinstance(exc.__cause__, ValueError):
raise ToolError(f"{exc}: {exc.__cause__}") from exc.__cause__
raise
@@ -386,17 +379,17 @@ def mount_mcp(app: Quart) -> None:
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
# The stateful default strands Claude Code after a container redeploy —
# it reconnects with the now-unknown session id, the server returns 404,
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
# and the client won't re-initialize on a 404,
# so the connection stays dead until a manual /mcp retry. Stateless makes
# every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream,
# which we don't use — tools are re-fetched on reconnect anyway. (Clients
# on the 2026-07-28 revision are stateless by protocol and take the SDK's
# modern path regardless; this governs the 2025-era handshake path.)
# which we don't use — tools are re-fetched on reconnect anyway. Clients
# on the stateless protocol revision take the SDK's modern path
# regardless; this governs the older handshake.
#
# DNS-rebinding protection is disabled: the SDK's default allow-list is
# just localhost variants, which means any deployment behind a reverse
# proxy (Traefik with a hostname like devassistant.traefik.internal,
# proxy (Traefik,
# Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
# model that protection addresses — a malicious browser page rebinding
# DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
+1 -1
View File
@@ -113,7 +113,7 @@ async def get_design_system(design_system_id: int) -> dict:
system in the inheritance chain, outermost ancestor first, so the house
style comes before this system's departures from it. `design_system`'s
own `guidance` field is only the departures. Read `resolved_guidance`
before writing UI; enter_project carries just the summary (#4045).
before writing UI; enter_project carries just the summary.
"""
uid = current_user_id()
system = await ds_svc.get_design_system(uid, design_system_id)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
"""Family canon MCP tools — the promotion engine's agent door.
Thin wrappers over services/family.py. The agent is the decider here: no
person approves a promotion, so the criteria are stated in these docstrings
+11 -13
View File
@@ -50,7 +50,7 @@ async def list_lessons(
offset: Skip this many before returning — page past the cap. `total`
is the unpaged count, so it says whether more remains.
project_id: Narrow to where a lesson was WRITTEN. 0 = every project.
A lesson is retrievable from anywhere regardless (step 3); this
A lesson is retrievable from anywhere regardless; this
filters the listing, not the reach.
unjudged: List only the lessons nobody has answered "which rule is
this an instance of?" for — no rule named, and no "no rule fits"
@@ -80,7 +80,7 @@ async def list_lessons(
)
labelled = await access_svc.label_shared_items(uid, items)
# One aggregate for the page, like the snippet listing — surfaced-vs-opened
# per lesson (#4196). An agent listing lessons can see which of its own
# per lesson. An agent listing lessons can see which of its own
# triggers are firing and which are not, which is the reading that leads to
# `update_lesson` rather than to a second lesson about the same failure.
await attach_usage(labelled)
@@ -188,7 +188,7 @@ async def create_lesson(
the binding choice its situation falls under. A lesson never
becomes a rule; it points at the one that governs it, and the rule
is then reachable through the situation the lesson describes
(milestone 440). Naming a rule here confirms the link.
Naming a rule here confirms the link.
no_rule: The reason no rule governs this situation, in a line — the
other answer to "which rule?". Give one or the other, not both.
When other lessons in the same situation also answered "no rule
@@ -251,7 +251,7 @@ async def create_lesson(
async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
"""A "no rule fits" answer is the moment to notice it is not the first
for this situation (#4634) — `convergence` names the group and the rule
for this situation — `convergence` names the group and the rule
it may be missing. Absent when there is no group."""
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group:
@@ -301,7 +301,7 @@ async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
# Every explicit open records the pull. A lesson is surfaced by the same
# retrieval as any other note, so a getter that records nothing would leave
# the kind permanently at zero pulls — reading as dead weight beside kinds
# that merely had a counter (#2476, the repeat of #2245).
# that have a counter.
# Read BEFORE the pull is recorded, so the number an agent is shown is the
# one that was true when it asked — otherwise every first read of a lesson
# reports a pull that is its own.
@@ -348,10 +348,9 @@ async def update_lesson(
system_ids: Replace the Systems this lesson is filed under. None
leaves unchanged; pass the FULL list, and `[]` to clear.
Here because `create_lesson` took it and this did not, so a lesson
written without one could never be filed afterwards — and the end
of a piece of work, when a lesson is usually written, is exactly
when that argument gets dropped (#4249). A System tag is how
A lesson written without one can be filed afterwards here — the
end of a piece of work, when a lesson is usually written, is
exactly when that argument gets dropped. A System tag is how
`list_system_records` gathers an area's pile, so an untagged
lesson is reachable by search and by nothing else.
rule_ids: Replace the rule(s) this lesson is an instance of. None
@@ -430,10 +429,9 @@ async def delete_lesson(lesson_id: int) -> dict:
deletion: its trigger is keyed to a situation nobody is in, and rewording
that with update_lesson keeps what was learned.
Deletion was always possible through `delete_note` — a lesson is a note and
the trash is kind-agnostic — but nothing said so, and a kind whose own
tools offer create/read/update reads as one you cannot retire (#2250, which
recorded exactly this for processes).
The same as `delete_note` — a lesson is a note and the trash is
kind-agnostic — offered here because a kind whose own tools offer only
create/read/update reads as one you cannot retire.
"""
uid = current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
+2 -2
View File
@@ -60,7 +60,7 @@ async def get_milestone(milestone_id: int) -> dict:
Each step also carries `log_count`. A step's STATUS is set by hand and a
plan is exactly where that goes stale; the count says which steps have a
work log that would say otherwise, so `get_task(id)` goes to the step
with a record rather than to each one in turn (#4241).
with a record rather than to each one in turn.
"""
uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id)
@@ -180,7 +180,7 @@ async def update_milestone(
raise ValueError(f"milestone {milestone_id} not found")
data = milestone.to_dict()
if closing:
# Family canon's milestone trigger (milestone 463): a plan closing on
# Family canon's milestone trigger: a plan closing on
# a platform is the moment to ask whether what it built is shared.
hint = await family_svc.milestone_trigger(uid, milestone)
if hint:
+1 -1
View File
@@ -1,4 +1,4 @@
"""Moments as MCP tools: the catalog, and the in-session corrections to it (milestone 458).
"""Moments as MCP tools: the catalog, and the in-session corrections to it.
A session needs the vocabulary in hand to mount a rule, to read a line that
names a moment, and to fix a misfire — and the operator's ruling is that the
+3 -3
View File
@@ -38,7 +38,7 @@ async def list_notes(
Optionally filter by a single tag (plain string, no # prefix) or by
`search_text`, which matches on MEANING — the same semantic match every
search surface uses (#2462; keyword ILIKE is only the fallback when the
search surface uses (keyword ILIKE is only the fallback when the
embedder is down). With `search_text`, results order by relevance; without
it, by last-updated descending. Reaches your records plus shared-project
records, like every other list.
@@ -97,7 +97,7 @@ async def get_note(note_id: int, project_id: int = 0) -> dict:
# Records the pull for ANY note kind, not just snippets: the auto-inject
# menu surfaces notes, tasks and processes too, so restricting this to
# snippets would leave those permanently at zero pulls and make them look
# like dead weight next to snippets that merely had a counter (#2085).
# like dead weight next to snippets.
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_note", project_id=project_id,
@@ -241,7 +241,7 @@ async def create_note(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
# Family canon's write-time triggers (milestone 463): a cited source of a
# Family canon's write-time triggers: a cited source of a
# pattern, or the same idea in another project on a shared platform.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Platform MCP tools — what a project is built on or ships as (milestone 463).
"""Platform MCP tools — what a project is built on or ships as.
Thin wrappers over services/platforms.py. The catalog is global; a project's
platforms decide which family ideas reach it.
+6 -8
View File
@@ -80,8 +80,7 @@ async def create_process(
body: The full prompt to run later (markdown). Required.
tags: Plain-string tags, no # prefix.
system_ids: Systems (subsystems/areas) to file this process under, so
an area-scoped read finds it. A process is a note, so it has always
been taggable in the data model; neither door offered it (#4249).
an area-scoped read finds it.
moments: The moments from list_moments this procedure is FOR — a
release procedure is a deliver, a review procedure a verify.
Loading the process then reaches each one, as well as its own
@@ -180,9 +179,8 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
out.update(await access_svc.describe_provenance(uid, note))
# A process is embedded like any other note, so auto-inject can surface one —
# and its menu header names THIS tool as the way to open that kind. Without
# this, the getter the product points at is the one getter that records
# nothing, and every process sits permanently at zero pulls looking like dead
# weight beside kinds that merely had a counter (#2476, the repeat of #2245).
# this, every process would sit permanently at zero pulls, looking like dead
# weight beside kinds that have a counter.
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_process", project_id=project_id,
@@ -250,9 +248,9 @@ async def delete_process(process_id: int) -> dict:
machine and auto-surfaces there, so a bad one is followed rather than merely
ignored — it costs more than a missing one.
Deletion was always possible through `delete_note` (a process is a note, and
the trash is kind-agnostic), but nothing said so, and a kind whose own tools
offer create/read/update reads as one you cannot retire (#2250).
The same as `delete_note` (a process is a note, and the trash is
kind-agnostic), offered here because a kind whose own tools offer only
create/read/update reads as one you cannot retire.
"""
uid = current_user_id()
loaded = await notes_svc.get_note_for_user(uid, process_id)
+23 -23
View File
@@ -51,8 +51,8 @@ async def list_projects() -> dict:
# How many of each kind the handshake lists. Enough to say what was worked on
# lately and what's open; the rest is a list_milestones / list_tasks call away.
# The handshake once carried every milestone's plan and came to ~222k
# characters, past what a client accepts as a tool result (#4045).
# Every milestone's plan would push the handshake past what a client accepts
# as a tool result.
_HANDSHAKE_MILESTONES = 5
_HANDSHAKE_OPEN_TASKS = 10
_HANDSHAKE_UNPLANNED = 10
@@ -121,9 +121,9 @@ async def enter_project(project_id: int) -> dict:
`pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
unclassified, largest: internal/api". Unclassified IS the todo: as you
— e.g. "shape accounting: 3100/4573 shapes accounted for — 12 canonical ·
2900 instance (estimate, computed <date>); 1473 unclassified, largest:
internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the
canon that's missing with create_snippet. A null line on a forge-served
@@ -137,13 +137,13 @@ async def enter_project(project_id: int) -> dict:
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
`platforms` (milestone 463) is what the project is built on or ships as
`platforms` is what the project is built on or ships as
— Android app, container image, Go, … — each with how it is known
(`declared` by a person, `detected` from a bound repo). They decide which
family ideas reach this project. An empty list on a project that plainly
ships something is worth correcting with set_project_platforms.
`family` (milestone 463) appears ONLY when this project has family canon
`family` appears ONLY when this project has family canon
to answer: how many canon ideas on its platforms are `unassessed`, how
many it `owed`s, and how many answers were given against an older canon
version (`needs_recheck`) — each with the list_family_adoptions call that
@@ -151,7 +151,7 @@ async def enter_project(project_id: int) -> dict:
(get_family_adoption, then assess_family_adoption, or classify the code
against it); the family-canon skill has the order.
`inception` (milestone 297) appears ONLY when the project is yours and
`inception` appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(design system, Systems, platforms), what to ask the
operator — once — and the decide_project_inception call that answers it;
@@ -210,15 +210,14 @@ async def enter_project(project_id: int) -> dict:
)
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
# records to Systems" is only executable if the list is in front of the
# agent when it writes — which it never was, and tagging stopped within
# three days of the feature landing (#2546's audit). Untagged writes now
# also ask with the vocabulary listed; this copy lets the first write tag.
# agent when it writes. Untagged writes also ask with the vocabulary
# listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id)
platforms = platforms_svc.members(
await platforms_svc.project_platforms(uid, project_id) or []
)
# The arrival-moment half of the bootstrap ask (#2683): session start is
# The arrival-moment half of the bootstrap ask: session start is
# when the agent has just read the project map and is not yet deep in a
# task — the one moment minting a starter vocabulary is cheap. The
# write-moment half rides untagged-record responses (attach_systems);
@@ -229,14 +228,15 @@ async def enter_project(project_id: int) -> dict:
uid, project_id
)
# The inception ask (milestone 297): a project nobody has decided on
# inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a
# decision is recorded; the key is ABSENT otherwise (#2483).
# The inception ask: a project nobody has decided on inherits nothing,
# silently — no design system, no Systems. Owner-only (deciding is the
# owner's), and only until a decision is recorded; the key is ABSENT
# otherwise.
inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project):
inception_ask = await inception_svc.inception_ask(uid, project_id)
# An AMBIENT source (#2477): top-N-by-recency, not a ranked choice, and
# An AMBIENT source: top-N-by-recency, not a ranked choice, and
# the readout counts it apart so dead-weight detection isn't poisoned by
# "recently updated in a project you opened".
record_surfaced(
@@ -247,7 +247,7 @@ async def enter_project(project_id: int) -> dict:
)
# A project need not have one, and most installs won't — null is ordinary
# here, not a missing prerequisite. Summary only: the guidance is ~9k of
# prose most sessions never use, so it's one call away (#4045).
# prose most sessions never use, so it's one call away.
design_system = None
if project.design_system_id:
design = await design_systems_svc.design_context(
@@ -269,7 +269,7 @@ async def enter_project(project_id: int) -> dict:
coverage = await coverage_svc.cached_coverage(
project.user_id or uid, project_id
)
# Arrival self-seed (#2802): a ledger that is absent or stale refreshes in
# Arrival self-seed: a ledger that is absent or stale refreshes in
# the BACKGROUND — entering is the moment the number is wanted, and the
# UI button must not be the only path. This enter stays fast; the next
# one carries the line. Forge-less projects exit the seed quietly.
@@ -303,7 +303,7 @@ async def enter_project(project_id: int) -> dict:
],
}
# Attached only when it applies — a key that usually says null trains
# readers to skip it (#2483), and this one exists to be acted on.
# readers to skip it, and this one exists to be acted on.
if omitted:
out["milestone_summary_omitted"] = (
f"{omitted} other milestone(s) not listed. "
@@ -318,7 +318,7 @@ async def enter_project(project_id: int) -> dict:
)
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
# Family canon (milestone 463 step 6): counts only, attached when one is
# Family canon: counts only, attached when one is
# non-zero. A readout must never fail the handshake it rides on.
try:
family = await family_adoption_svc.family_readout(uid, project_id)
@@ -329,7 +329,7 @@ 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,
# The core reply shape: 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.
@@ -393,7 +393,7 @@ async def create_project(
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
A project's inheritance is a decision, not a default:
before calling, ask the operator the three inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
@@ -446,7 +446,7 @@ async def decide_project_inception(
platforms: list[str] | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
or re-decide later (milestone 297).
or re-decide later.
Owner-only. Applies the effects through the ordinary tools' paths —
set_project_design_system and the standard Systems seed — and writes the
+1 -1
View File
@@ -1,4 +1,4 @@
"""Judging what a retrieval arm offered (#4772).
"""Judging what a retrieval arm offered.
`retrieval_telemetry` says what the reader did with each line — opened it or
not. These two say whether the line RELATED, which is a reading only someone
+1 -1
View File
@@ -1,4 +1,4 @@
"""Reading and moving a retrieval surface's floor and budget (#4102).
"""Reading and moving a retrieval surface's floor and budget.
The write half of the loop `retrieval_telemetry` opens. That tool says what a
bar did; these say what the bar IS, and let it be changed with the argument
+19 -28
View File
@@ -5,12 +5,8 @@ edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
service, and the record shape comes from rule_brief / rule_detail there rather
than being rebuilt here.
(The header used to say "Sixteen tools" and had been wrong for two milestones;
the count lives in the registration test, which fails when it drifts.)
Destructive ops (delete_*) require confirmed=True; otherwise return a
preview-style warning. Mirrors the pattern in delete_event and the design
spec.
Destructive ops (delete_*) require confirmed=True; otherwise they return a
preview-style warning.
"""
from __future__ import annotations
@@ -53,9 +49,9 @@ async def create_rulebook(title: str, description: str = "") -> dict:
"""Create a new rulebook (a themed grouping of GLOBAL rules).
A rule in a rulebook is global: it applies in every project its owner works
on, and reaches a session by retrieval when the work makes it relevant
(milestone 414). There is no subscribing a project to a rulebook, and no
muting one per project — that machinery is gone. So a rulebook's rules must
on, and reaches a session by retrieval when the work makes it relevant.
There is no subscribing a project to a rulebook, and no muting one per
project. So a rulebook's rules must
stay general, agnostic to any single project. Project-specific rules go in
create_project_rule.
@@ -198,8 +194,8 @@ def _rule_summary(r, moments: list[str] | None = None) -> dict:
"""The list-row shape for a rule: what an agent needs to APPLY it. The
full record (why, how_to_apply, timestamps) is get_rule's job.
One line, because the shape itself lives in the service — this was one of
three hand-written copies that had already drifted apart (note 3026).
One line, because the shape itself lives in the service, shared with
every other door that lists rules so the copies cannot drift.
`moments` rides along only when the rule is mounted (rule_brief drops a
None), so an unmounted rule's row says nothing rather than "[]".
"""
@@ -465,10 +461,9 @@ async def create_rule(
This field is also the rule's RETRIEVAL SURFACE — it and the
statement are what a search is matched against, so it should
carry the SYMPTOM, not just the situation: the words someone
would actually type while stuck. Measured (note 3078): a rule
whose trigger named only its situation did not surface at all
for the problem it solves; adding the symptom to the same field
brought it back as the top hit. Where a rule prevents a specific
would actually type while stuck. A rule whose trigger names only
its situation can fail to surface for the problem it solves;
adding the symptom to the same field makes it the top hit. Where a rule prevents a specific
failure, put that failure's vocabulary here — the error text,
the wrong behaviour, the dead end.
The two spellings, side by side:
@@ -524,7 +519,7 @@ async def create_rule(
dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
if dup is not None:
return dedup_svc.duplicate_response(dup, "rule")
# Before the create, so the new rule cannot find itself (#4134).
# Before the create, so the new rule cannot find itself.
overlaps = await dedup_svc.find_overlapping_rules(
uid, title, statement, when_to_apply,
)
@@ -666,8 +661,8 @@ async def update_rule(
correct. Ordinary edits to an existing preference belong in
update_preference, which asks for what taught the change.
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since
milestone 394, so a rule with no trigger is not a quiet rule — it is one
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded, so a
rule with no trigger is not a quiet rule — it is one
no session will ever be shown. `system_ids` REPLACES the rule's areas
(pass [] to clear), and they decide which PROJECTS a rule binds by area.
`moments` REPLACES the moments it arrives at the same way — see
@@ -684,8 +679,7 @@ async def update_rule(
ever types. `rule_document()` puts this field in twice, as the title's
other half and again above the body, so it dominates the vector: a trigger
describing the abstraction collapses the record toward its title and the
rule never arrives. Measured in #3835 across 113 rules, and again in #3855
on six preferences written before this was understood.
rule never arrives.
So when you touch a rule with an old trigger, re-read it against the query
that would have to match it — the command about to run, the code being
@@ -835,8 +829,7 @@ async def create_preference(
arose_from_id: The task or note that taught this. Required; see above.
system_ids: Ids from list_canonical_systems — the global AREAS this
preference is about, which is what lets it reach a session working
in that area. `update_preference` took this and create did not, so
a preference could only be filed after the fact (#4249).
in that area.
moments: Moments from list_moments this rule arrives at — whenever
one happens, the rule is delivered, whatever the words of the work
look like. Mount a rule here when it is about WHEN something is
@@ -978,9 +971,8 @@ async def rule_history(rule_id: int, version_id: int = 0) -> dict:
Read this before you argue with a rule, and before you rewrite one. A
rule that has been reworded may have been reworded for a reason you are
about to rediscover the hard way — and the wording it replaced is often
the fastest way to see what the current one is guarding against. The
rescoping of rule 79 is the case this exists for: the superseded
statement had to be hand-copied into a task log to survive the edit.
the fastest way to see what the current one is guarding against, and it
survives the edit here without anyone copying it elsewhere.
EACH ENTRY HOLDS THE TEXT THE EDIT REPLACED, not the text it introduced.
So "what did this say before the most recent change?" is the first entry,
@@ -988,8 +980,7 @@ async def rule_history(rule_id: int, version_id: int = 0) -> dict:
with get_rule. Pair the two and you have the diff.
An empty history is ordinary and means the rule has never been reworded,
not that its history was lost. Nothing is written before milestone 323,
so a rule edited before then starts empty too.
not that its history was lost.
Args:
rule_id: The rule whose history to read.
@@ -1135,7 +1126,7 @@ async def unrelate_rules(relation_id: int) -> dict:
raise ValueError(f"relation {relation_id} not found")
return {"deleted": relation_id}
# ── The staleness sweep (milestone 312) ────────────────────────────────
# ── The staleness sweep ────────────────────────────────
async def rules_due_for_verification(
older_than_days: int = 0, never_only: bool = False,
+56 -79
View File
@@ -22,17 +22,16 @@ from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summ
# A matched chunk is at most _CHUNK_CHAR_BUDGET (1400) characters, and it is
# the evidence the ranking was built on — so it is worth more room than the 240
# characters of document opening this used to send. Elision inside a chunk is
# far less lossy than a head cut of a whole record: the region is already the
# right one.
# the evidence the ranking was built on, so it gets most of that room. Elision
# inside a chunk is far less lossy than a head cut of a whole record: the
# region is already the right one.
_EXCERPT_CHARS = 1000
# The kinds `content_type` accepts are DERIVED from the facet table, not listed
# again here — that table is where a kind is declared (#3161), and a second
# hand-kept copy in this module is precisely how the agent's door came to offer
# two kinds while browse offered nine (#4250). `content_type_filters` carries
# again here — that table is where a kind is declared, and a second hand-kept
# copy here would let the agent's door and browse offer different kinds.
# `content_type_filters` carries
# the mapping, the 'all'/'note' special cases and the refusal.
#
# These two do not reach `semantic_search_notes` at all: they have their own
@@ -91,7 +90,7 @@ async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> dict:
"""Milestones by meaning — "is there already a plan for this?" (milestone 415).
"""Milestones by meaning — "is there already a plan for this?".
Its own result shape, like rules: a milestone is a plan with progress, not
a note with a body. The plan itself is left out — get_milestone reads it —
@@ -100,7 +99,7 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
actually hit, with `matched_is` saying whether it is that passage or
merely the body's opening. Recognising a plan means recognising the part
of it you were asking about, and a description written at the start need
not mention that part (#4243).
not mention that part.
Not part of content_type="all", whose results are note-shaped.
"""
report: dict = {}
@@ -124,7 +123,7 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
# The plan body stays out — get_milestone reads it — but the
# passage that MATCHED comes along, because a plan is
# recognised by the part of it the query was about and a
# description need not mention that part at all (#4243).
# description need not mention that part at all.
**excerpt_fields(
m.body or "", chunks.get(int(m.id)), _EXCERPT_CHARS,
key="matched",
@@ -142,13 +141,13 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict:
"""Systems by meaning — "where does this belong?" (#4251).
"""Systems by meaning — "where does this belong?".
A System's `description` is a charter: several hundred words saying what
belongs in that area and what does not. `list_systems` enumerates them and
`system_id` filters by one, so before this a System could NARROW a search
and could never be the answer to one — an agent asking where a record
belonged had to read every charter or guess.
`system_id` filters by one; this is the search where a System is the
answer, so an agent asking where a record belongs need not read every
charter.
Its own result shape and its own search, not a `content_type` over notes,
because the question is different: "where does this belong?" is not "what
@@ -158,7 +157,7 @@ async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict
The charter's `matched` passage comes along rather than the whole thing.
A charter is long and the paragraph that decides where a record belongs is
the one worth reading; the rest is get_system (#4243).
the one worth reading; the rest is get_system.
The id that comes back is the one `search(system_id=…)`,
`list_system_records` and every `system_ids` argument take — so the answer
@@ -191,16 +190,11 @@ async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict
def result_excerpt(note, chunk: dict | None) -> dict:
"""The part of a record a caller judges "should I open this?" on.
This used to be `(note.body or "")[:240]` — the document's opening, with
no marker that anything had been cut, so a 240-character preview of a
4000-character record was indistinguishable from a complete short one.
The opening is the wrong span. The match was semantic and per-chunk, and
`semantic_search_notes` collapses to best-chunk-per-note — so the system
already knows which passage earned the hit and used to discard it. A
record could rank first on its sixth paragraph and be previewed by its
first, which the search had already judged less relevant, and the caller
would decide from that and never know (#4243).
The document's opening is the wrong span. The match is semantic and
per-chunk, and `semantic_search_notes` collapses to best-chunk-per-note, so
the passage that earned the hit is known. A record can rank first on its
sixth paragraph; previewing its first would show the caller the part the
search judged less relevant. When the span is cut, the result says so.
The choice of span lives in services/text.py, shared with the web's
knowledge search, so the two doors cannot drift on which text a reader is
@@ -253,8 +247,7 @@ async def search(
THREE KINDS WITH THEIR OWN SEARCH AND THEIR OWN RESULT SHAPE,
because each answers a question no note search can:
'rule' (the operator's standing instructions — searchable by
meaning since milestone 307), 'milestone' ("is there already a
'rule' (the operator's standing instructions), 'milestone' ("is there already a
plan for this?"), and 'system' ("where does this belong?" — a
System's charter says what belongs in an area and what does not,
and the id it returns is the one `system_id` and `system_ids`
@@ -321,7 +314,7 @@ async def search(
uid, q, limit=limit, **filters,
project_id=project_id or None,
system_id=system_id or None,
# A LESSON is reachable from any project (milestone 385). The kind
# A LESSON is reachable from any project. The kind
# exists to carry an insight to the next project, so a project filter
# that hid it would hide it precisely where it is worth having. Only
# the project filter widens — everything else about the scoping holds,
@@ -372,12 +365,11 @@ async def retrieval_telemetry(
) -> dict:
"""What the retrieval telemetry says about YOUR surfaces, over a window.
The read half of the tuning loop, whose write half is `tune_retrieval`
(#2975, #4102). Reach for it before moving any floor or budget, and read
the records it names rather than its percentiles alone: this readout has
been measured pointing the WRONG WAY — 69 consecutive declines where every
percentile said "lower the bar" and the refused record was a false positive
— so `near_miss_samples=5` and opening the ids it returns is the step that
The read half of the tuning loop, whose write half is `tune_retrieval`.
Reach for it before moving any floor or budget, and read the records it
names rather than its percentiles alone: every percentile can say "lower
the bar" while each refused record is a false positive, so
`near_miss_samples=5` and opening the ids it returns is the step that
separates a real miss from a bar doing its job.
Five readouts, from the five tables built for them:
@@ -418,29 +410,21 @@ async def retrieval_telemetry(
moment you are making a decision, not for every readout.
`near_misses` is `null` when no declining call in the window measured it —
rows written before #3670 shipped cannot know. That is "not measured", not
older rows may not carry the measurement. That is "not measured", not
"nothing came close"; a 0.0 there would be a claim about the corpus
invented out of a caller's silence.
A NULL HERE NOW MEANS ONE THING, which it did not at first. A semantic
search returns nothing three ways WITHOUT having run — an empty query, an
unavailable embedder, and a failed database query — and each used to write
a row indistinguishable from a ranker that declined (#3765). Those calls no
longer write a row at all, on the same reasoning that already keeps a blank
command out of the log: a row there reports a call that never happened and
drags the clear rate down with phantom declines. So a null is "searched,
and nothing came close", and a broken search shows up as a WARNING in the
application log rather than as a quiet zero in here.
A search that returns nothing WITHOUT having run — an empty query, an
unavailable embedder, a failed database query — writes no row: a row would
report a call that never happened and drag the clear rate down with
phantom declines. So a null is "searched, and nothing came close", and a
broken search shows up as a WARNING in the application log rather than as
a quiet zero in here.
THERE IS NO `cleared_threshold` ANY MORE, and if you remember one, that
memory is of a tautology (#3670). The search applies the bar before
returning, so every returned result cleared it by construction and a call
with no results has no score to compare: the field was true exactly when
`result_count > 0`, i.e. it was `calls - zero_result_calls` under a name
that promised a second opinion. `zero_result_calls + cleared_threshold ==
calls` held on all nineteen readings ever taken. The reading procedure
built on it — "clears its bar on nearly every call" — asked you to compare
a number with itself.
There is no "cleared the threshold" count, because it would be a
tautology: the search applies the bar before returning, so every returned
result cleared it, and that count would always equal
`calls - zero_result_calls`.
CHECK `suppression` BEFORE CONCLUDING ANYTHING FROM `zero_result_calls`. A
zero-result call is two different events wearing one number: the ranker
@@ -452,8 +436,8 @@ async def retrieval_telemetry(
`suppression` is `null` when NO row in the window reported it, and that is
"not measured here", NOT "none suppressed". Surfaces that pass their
exclusions into the search never see what was dropped, so they cannot say.
Do not read a null as a zero: reading an artifact as a measurement is how
this surface got mis-scoped once already (#3311, #3497).
Read a null as "unknown", never as a zero: an artifact read as a
measurement mis-scopes the surface.
`usage` — NOTES ONLY, from `note_usage_events`, at the per-note grain
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
@@ -477,10 +461,10 @@ async def retrieval_telemetry(
about a record nothing chose). Read it as: of the distinct notes THIS
surface put in front of the agent, how many did the agent then open?
It is an UPPER BOUND per surface: a pull records the door it came
It is an UPPER BOUND per surface: a pull records the door it came
through, not the surface that led there, so a note surfaced by two surfaces
and opened once counts for both — attribution would need the session
identity #2085 declined to invent. `by_source_failed: true` means that one
and opened once counts for both — attribution would need a session
identity the logs do not carry. `by_source_failed: true` means that one
query failed while the rest of the readout stood.
`rule_usage` — the same question for RULES, from `rule_usage_events`:
@@ -489,7 +473,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
definition (agent pulls over RANKED surfacings).
`applied`, `departed` AND `distinct_rules_acted` ARE WHAT HAPPENED AFTER
THE RULE WAS OPENED (#4213). A pull says the rule was read; these say it
THE RULE WAS OPENED. A pull says the rule was read; these say it
changed something. `applied` counts rules followed, `departed` rules
deliberately not followed — kept apart rather than summed, because a
departure carries the reason the agent gave and is evidence about the
@@ -511,8 +495,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
a pull can settle. `ambient` counts BULK DELIVERIES: the `rules_payload`
surfaces (`enter_project`, `get_project`, `get_milestone`,
`start_planning`, `get_task`), which hand over the whole applicable set at
once with nobody choosing anything — plus, in rows older than milestone
394, the SessionStart preload it removed. A large `ambient` says a bulk set
once with nobody choosing anything. A large `ambient` says a bulk set
is big and arrives often — never that it is useful, and never that it is
read.
@@ -523,13 +506,10 @@ It is an UPPER BOUND per surface: a pull records the door it came
those same rules over time: a set surfaced thousands of times and opened
never is the dead-weight signal, one level up.
Read it against `sources["write_path_rule"]`. That arm was once believed
never to decline — the reading that scoped #3311 — but it was the arm's
`retrieval_logs` row being written only on calls that FOUND something, so
the zeros were missing rather than absent (#3497). Measured since, it
declines the large majority of its calls like any other surface.
Read it against `sources["write_path_rule"]`, which declines the large
majority of its calls like any other surface.
`system_usage` — the RULINGS arm (#4769), from `system_usage_events`: how
`system_usage` — the RULINGS arm, from `system_usage_events`: how
often an area's rulings were shown because a command (`rulings_pre_tool`)
or an edit (`rulings_write_path`) touched its files, how often a System
was then opened (`mcp_get_system`), and `by_system` naming the areas, most
@@ -539,7 +519,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
a ratio of opens would read near zero on an arm that is working.
`system_usage_failed: true` means its read broke and the zeros mean nothing.
`judged` — whether the lines RELATED (#4772), from `retrieval_judgments`:
`judged` — whether the lines RELATED, from `retrieval_judgments`:
per source, a reviewer's verdicts (`on_point` / `adjacent` / `unrelated`)
on a sample of logged menus, `within_budget` against `beyond_budget` and
`by_rank`. Every usage block above counts what the reader DID; a line
@@ -564,8 +544,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
across a deploy. A counter added last week, read over a 30-day window,
reports a real count against an imagined denominator — and the result is
a plausible fraction rather than an obvious zero, which is what makes it
dangerous. That reading cost milestone #379 five steps aimed at a defect
that did not exist.
dangerous.
`covers_window` is null, never false, when nothing was ever recorded:
"no measurement" is not "partial measurement", the same distinction
@@ -583,10 +562,9 @@ It is an UPPER BOUND per surface: a pull records the door it came
they are zeros meaning "could not find out", not "nothing happened" — do
not report a pull-through from a block carrying that flag.
`warnings` IS THE PART TO READ FIRST (#3431). Everything above is a
distribution; this is a verdict, and it exists because the same four
checks were being redone by hand on every reading and were easy to
forget. An EMPTY LIST means checked and clean — it is always present, so
`warnings` IS THE PART TO READ FIRST. Everything above is a
distribution; this is a verdict, the checks a careful reader would
otherwise redo by hand on every reading. An EMPTY LIST means checked and clean — it is always present, so
its emptiness is an answer rather than a gap. Each entry carries the
numbers that triggered it, so you can disagree with the rule instead of
having to redo the arithmetic:
@@ -596,7 +574,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
Only ever raised for unbidden arms known to log unconditionally: a
search returning a list every time is doing its job, and an arm whose
zeros were never written would flag a LOGGING bug while pointing you at
a threshold, which is #3497 exactly.
a threshold.
- `band_hugs_floor` — the weakest tenth of what an arm returns sits on
its floor. The bar is doing the selecting and the score is not, so
moving that floor changes how MUCH you get, not how good it is.
@@ -615,8 +593,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
For RULES there is no judged sample: a rule set aside again and again
is a trigger to fix (`update_rule(when_to_apply=...)`), not a floor.
- `read_and_unacted` — distinct rules OPENED in the window that recorded
no outcome, against the ones that did. The failure milestone 419 was
opened on, and the worse sibling of `surfaced_never_pulled` above: a
no outcome, against the ones that did. The worse sibling of `surfaced_never_pulled` above: a
rule nobody opens is cheap, while a rule read and silently unchanged is
indistinguishable from one that worked. It does not say which of the two
causes it is — a rule mis-triggering, arriving where it does not apply,
@@ -658,8 +635,8 @@ It is an UPPER BOUND per surface: a pull records the door it came
asked for, query text included, and is not a shared record kind.
`read_failed: true` means the query itself failed — deliberately distinct
from an empty window, because those two looked identical for weeks once
(#2663) and every counter silently read zero.
from an empty window, which would otherwise read as every counter at
zero.
Args:
days: window size, default 30. Clamped to at least 1.
+22 -22
View File
@@ -1,11 +1,11 @@
"""Shape-ledger MCP tools — the classification write/read surface (#2789).
"""Shape-ledger MCP tools — the classification write/read surface.
The accounting model (note 2786): the snippet library records CANON (small);
The accounting model: the snippet library records CANON (small);
the ledger accounts for EVERY extracted shape (total). These tools are how
agents move shapes out of `unclassified` — the todo state — and how they read
what still needs judgment. The ledger rows themselves are fed by the coverage
refresh; a judgment given with `repo` may also land on a shape written this
turn that the sync has not seen yet (milestone 439).
turn that the sync has not seen yet.
"""
from __future__ import annotations
@@ -19,7 +19,7 @@ async def classify_shapes(
) -> dict:
"""Record judgments for a project's code shapes — in batch, as rows.
EVERY shape in a bound repo should end up classified (note 2786):
EVERY shape in a bound repo should end up classified:
- `instance` of snippet N — it conforms to recorded canon (family-level
canon in another project counts; that fully accounts for the shape).
- `variant` of snippet N — a deliberate, named departure. `reason`
@@ -50,7 +50,7 @@ async def classify_shapes(
one-off-handler, test-helper, convention-plumbing, pure-helper,
generated, script, typed-record) so the ledger can be filtered
and aggregated by kind of one-off — the prose stays the record.
uses is an OPTIONAL list of snippet ids this shape CALLS (#2870):
uses is an OPTIONAL list of snippet ids this shape CALLS:
conformance (status + snippet_id) says what shape it is, uses
says which canonical helpers it consumes — a service function
can be an instance of the service-function convention AND use
@@ -106,7 +106,7 @@ async def list_shapes(
Args:
status: canonical | instance | variant | exempt | scoped | unclassified.
`scoped` (#2869) is the sync's mechanical stamp on one-offs by
`scoped` is the sync's mechanical stamp on one-offs by
construction (a Vue component's scoped <style> rules and its
<script setup> functions): accounted for, not judged, still
proposed against / grouped / flagged, and overridable by any
@@ -115,7 +115,7 @@ async def list_shapes(
(the coverage line's "largest" dirs go straight in here).
snippet_id: rows classified against this snippet (instance/variant
of it — conformance).
uses: rows that CALL this snippet (#2870) — the consumer map proper,
uses: rows that CALL this snippet — the consumer map proper,
whatever shape each row is itself; edges come from judgments
(classify_shapes uses=), the write-path hook, and the proposer's
by-name reference hits.
@@ -126,7 +126,7 @@ async def list_shapes(
set — no commits, shas or timestamps. THE form for an audit:
a full 500-row page fits the tool budget. The default rows carry
everything (shape_history-grade bookkeeping).
proposal: the proposer's queue (#2792) — "any", "canon" (rows the
proposal: the proposer's queue — "any", "canon" (rows the
machine thinks are an instance of a snippet: `proposal` carries
snippet_id, basis, score), "derive" (rows that repeat with NO
canon: `proposal.group` names the family), or one basis
@@ -134,27 +134,27 @@ async def list_shapes(
a canon family idea's reference — another project's, often
another language's — that the body means: the top match over
every snippet you can read, on a platform the project shares.
flag: the divergence readout (#2793) — "divergence": shapes new
flag: the divergence readout — "divergence": shapes new
since the previous refresh in a directory where one canon
dominates the judged siblings and NOT proposed as that canon
(`diverges_from` names it: button B where button A is canon —
classify it: instance if it should use the canon, variant with
the why if deliberate); "recheck": judged instances/variants
whose body changed since judged (the judgment stands; confirm
it again with classify_shapes, or re-judge); "unused-css"
(milestone 302): live css rules no file's markup names — a
it again with classify_shapes, or re-judge); "unused-css":
live css rules no file's markup names — a
deletion candidate to look at, never auto-deleted. Transition
classes and concatenated names are read (#2970), so the list is
classes and concatenated names are read, so the list is
worth acting on; a name assembled in a script still is not.
Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Every css row carries `used_by` {count, paths} — the
files whose markup names its class (milestone 302, the CSS consumer
files whose markup names its class (the CSS consumer
map: a scoped rule is used by its own template; a shared recipe by
many; a count of 0 is "no template names it"). Each row's
`classified_by` says who judged: agent /
audit / import are judgments; `mechanical` is the canonical stamp the
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
sync applies; `hook` is write-path EVIDENCE — the session pulled
a snippet and then wrote code referencing/resembling it, so the shape
was stamped an instance with the evidence in `reason`. A hook row is
overridable by any classify_shapes call; it never overrides yours.
@@ -223,7 +223,7 @@ async def classify_shapes_by_rule(
via: "agent" (default) | "audit" | "import".
reason_code: Optional catalogue code beside the reason (see
classify_shapes) — a sweep is exactly where one applies.
uses: Optional snippet ids every matched shape CALLS (#2870) — e.g.
uses: Optional snippet ids every matched shape CALLS — e.g.
"every *_scheduler.py symbol uses ScheduledJob".
include_judged: By default only unjudged rows are touched —
`unclassified` and the sync's mechanical `scoped` stamp — a
@@ -254,7 +254,7 @@ async def shape_history(
project_id: int, path: str, symbol: str = "", limit: int = 200
) -> dict:
"""What was used here, when, and why — a shape's (or a directory's)
history from the ledger (#2793).
history from the ledger.
`shapes` are the current rows at `path` (a file, or a directory and
everything beneath it; `symbol` narrows to one definition) with
@@ -304,7 +304,7 @@ async def stamps_to_review(project_id: int, top: int = 10) -> dict:
Disagreement is judged by FAMILY (a sync helper and an async one are one
family), and a method defined beside a member class counts as part of the
shape rather than against it — so a model convention that covers both the
class and its `to_dict` reads as coherent, which it is (#4220). Each
class and its `to_dict` reads as coherent, which it is. Each
entry carries `families` and `forms`, the majority `family`, `attached`
(minority rows excused as methods of a member), `strangers` — THE ROWS
THAT DO NOT FIT, which is what you judge — and `unattended` / `weak_rows`,
@@ -330,7 +330,7 @@ async def confirm_shape_proposals(
) -> dict:
"""Confirm the proposer's canon proposals you have reviewed, in batch.
The machine proposes, judgment classifies (#2792): each matching row —
The machine proposes, judgment classifies: each matching row —
live, unclassified, carrying a `proposal` with a snippet_id — becomes
`instance` of that snippet, classified_by="agent", reason naming the
basis and score. Narrow to what you actually looked at: at least one of
@@ -359,7 +359,7 @@ async def confirm_shape_proposals(
async def refresh_pattern_coverage(project_id: int) -> dict:
"""Seed or refresh the project's shape ledger NOW, and return the readout.
The synchronous form of the arrival-moment background seed (#2802):
The synchronous form of the arrival-moment background seed:
downloads the bound repos through the OWNER's forge connections, upserts
every extracted shape into the ledger (new shapes arrive `unclassified`),
re-stamps snippet reference locations as canonical, and recomputes the
@@ -372,7 +372,7 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
owner adds one (Settings → Integrations → Git Forges); no served repo →
bind_repo on a host a connection serves.
The refresh is also when the mechanical proposer runs (#2792): with the
The refresh is also when the mechanical proposer runs: with the
repo bodies in hand it matches every changed unclassified shape against
canon and records proposals (see list_shapes proposal=), then regroups
the derive-first candidates. Semantic matching is capped per refresh, so
@@ -382,9 +382,9 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
confirmation), `derive_groups` (the biggest repeats-with-no-canon
families, each css one with `consumers` — the files whose markup
render it, milestone 302), `unused_css` (css rules no template names —
render it), `unused_css` (css rules no template names —
counting a `<Transition name=>`'s generated classes and concatenated
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies
names as named; None where the map has no evidence of templates), `derive_new` (copies
that joined a family since the previous
refresh — the drift to act on now: derive the canon, don't queue an
audit), `proposer` (what this refresh examined) — plus
+12 -12
View File
@@ -187,13 +187,13 @@ async def create_snippet(
and force is almost never the right answer to them. Only then a semantic
check, held to a high bar so that VARIANTS of one component are not refused:
`.btn-primary` and `.btn-secondary` read alike and are two different things,
so record both (#2518).
so record both.
"""
if not (name or "").strip() or not (code or "").strip():
raise ValueError("create_snippet requires a non-empty name and code")
uid = current_user_id()
# The NAME is the title (milestone 427); the trigger rides in `data` and
# The NAME is the title; the trigger rides in `data` and
# joins the title only in the embedded document.
title = name.strip()
body = snippets_svc.compose_body(
@@ -206,7 +206,7 @@ async def create_snippet(
uid, title, body, project_id=project_id or None,
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
# The artefact itself, not just its description — the gate compares
# location and code before it compares prose (#2518).
# location and code before it compares prose.
code=code,
locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
data=snippets_svc.compose_data(name=name, when_to_use=when_to_use),
@@ -227,7 +227,7 @@ async def create_snippet(
advice = snippets_svc.trigger_advice(when_to_use)
if advice:
data["trigger_advice"] = advice
# Family canon (milestone 463): the same shape recorded in another project
# Family canon: the same shape recorded in another project
# on a shared platform opens an evaluation of the earlier one.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
@@ -246,7 +246,7 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
A record kept VERBATIM is confirmed by containment. A deliberately
ANNOTATED one — commentary the source does not carry — cannot be, so it
reads "current" on the authority of a standing `ok` verdict stamped at
the very commit just fetched (#2782); `verification` in the same payload
the very commit just fetched; `verification` in the same payload
shows that basis. Edit the record, or let the file move past that commit,
and it reads "diverged" again until someone re-runs verify_snippet.
@@ -270,14 +270,14 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
if note is None:
raise ValueError(f"snippet {snippet_id} not found")
data = snippets_svc.snippet_to_dict(note)
# Forge-checked freshness (#2690): attaches body_source/body_freshness
# Forge-checked freshness: attaches body_source/body_freshness
# when the instance has a forge; a no-forge instance sees no new fields.
await snippets_svc.attach_live_body(note, data)
data.update(await access_svc.describe_provenance(uid, note))
# A "pull" is an explicit open, so it's recorded HERE rather than in
# snippets_svc.get_snippet — the service is also reached by update/merge
# paths, and counting those would inflate exactly the number that is
# supposed to mean "someone chose to look at this" (#2085).
# supposed to mean "someone chose to look at this".
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_snippet", project_id=project_id,
@@ -285,8 +285,8 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
await systems_tools.attach_systems(
uid, note.user_id, data, note.id, note.project_id
)
# The structured consumer map (#2789): ledger rows judged against this
# snippet. Attached only when non-empty (#2483) — and never for projects
# The structured consumer map: ledger rows judged against this
# snippet. Attached only when non-empty — and never for projects
# the caller can't read.
from scribe.services import shape_ledger as shape_ledger_svc
@@ -296,7 +296,7 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
if consumers["variants"]:
data["variants"] = consumers["variants"]
if consumers.get("uses"):
# The call sites (#2870): shapes that use this snippet, whatever
# The call sites: shapes that use this snippet, whatever
# shape they are themselves.
data["uses"] = consumers["uses"]
return data
@@ -409,7 +409,7 @@ async def verify_snippet(
CONSUMERS you enumerate while checking ("all N call sites still route
through it") are ledger rows, not detail prose: classify each consuming
definition as an `instance` of this snippet with classify_shapes — prose
cannot be sorted, queried, or diffed (note 2786's lesson), and the rows
cannot be sorted, queried, or diffed, and the rows
are what make "what uses this?" answerable forever. `detail` keeps the
WHY and what changed, nothing that belongs in a row.
@@ -514,7 +514,7 @@ async def update_snippet(
if note is None:
raise ValueError(f"snippet {snippet_id} not found")
if system_ids is not None:
# The CALLER, not the owner (#4249). `update_snippet` above already
# The CALLER, not the owner. `update_snippet` above already
# raised PermissionError if this user may not write, so the tagging
# runs with the actor's own System visibility rather than the owner's.
await systems_svc.set_record_systems(uid, snippet_id, system_ids)
+22 -23
View File
@@ -23,21 +23,20 @@ from scribe.services.system_usage import record_system_pulled
# Below this, a project is young enough that the mild "which area is this
# about?" question stays proportionate; at or above it, a zero-Systems project
# has demonstrated that the question never converts (#2683 — Minstrel reached
# 282 records without a single System) and the ask escalates.
# has shown that the mild question does not get answered, and the ask
# escalates.
_BOOTSTRAP_MIN_RECORDS = 20
_BOOTSTRAP_TITLES = 6
# The standard vocabulary (#2798): area names that recur across software
# The standard vocabulary: area names that recur across software
# projects, offered so "CI & Release" means the same thing in every project
# on the instance. Consistency comes from the shared names — NOT from asking
# the operator to approve each System; agents mint directly. Generic by
# design (rule #115): archetypes any codebase could have, never one
# install's subsystems. Mint freely beyond the list; the duplicate gate
# guards sprawl.
# The standard vocabulary lives in the GLOBAL canonical catalog since
# milestone 307 — the inception seed mints it and this ask names it, one list
# for both, now a table so a rule can reference an area by id (note 3026).
# design: archetypes any codebase could have, never one install's
# subsystems. Mint freely beyond the list; the duplicate gate guards sprawl.
# The vocabulary lives in the GLOBAL canonical catalog — the inception seed
# mints it and this ask names it, one list for both, in a table so a rule can
# reference an area by id.
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
@@ -45,13 +44,13 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
The generic zero-state question habituates: identical on every record,
maximal in scope ("invent the taxonomy"), asked at wrap-up time — so
organic sessions skip it forever and only audit-shaped sessions ever mint
(#2683). What separates the nudges that convert from the prose that
organic sessions skip it forever and only audit-shaped sessions ever mint.
What separates the nudges that convert from the prose that
doesn't (the duplicate gate, the prior-art "already defined in 2 files")
is the project's OWN evidence in the ask — so this one carries the record
count and the recent titles, and asks for a concrete deliverable: create
a starter set directly, preferring the standard cross-project names.
Deliberately NOT an approval flow (#2798): the operator is not a
Deliberately NOT an approval flow: the operator is not a
permission gate for vocabulary; the standard names carry consistency.
Self-retiring by construction: callers only reach for it while the
@@ -102,12 +101,12 @@ async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
arrives in-band at the exact moment a record is touched. It is ONE
question regardless of vocabulary state ("which area is this about, and is
it modelled?"); only the vocabulary listing varies, because an empty
vocabulary is not an exemption — it is the question at its most urgent
(#2562, #2569). Instruction prose alone demonstrably doesn't fire at write
time; in-band behavior (the duplicate gate) does.
vocabulary is not an exemption — it is the question at its most urgent.
Instruction prose alone does not fire at write time; in-band behavior
(the duplicate gate) does.
In a MATURE zero-Systems project the question escalates to the bootstrap
ask instead (#2683): the mild form demonstrably never converts there, and
ask instead: the mild form does not get answered there, and
an evidence-carrying, deliverable-shaped ask is the form that does.
"""
# Fail-open like the dedup gate: a hint must never break the call.
@@ -147,7 +146,7 @@ async def attach_systems(
tagged record shows its areas (the touching-a-System reflex needs the
affiliation visible on read, not just settable on write), an untagged
project record carries the question instead. Neither field is ever
attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a
attached empty (same reasoning as supersession_svc.attach_relations — a
field that always says nothing trains readers to skip fields). The hint
goes only to the record's owner: tagging someone else's record in someone
else's project is not the caller's call to make. Fail-open — decoration
@@ -181,7 +180,7 @@ async def create_system(
yet — the same two-or-more test snippets use. Don't wait to be asked to
name an area that plainly exists in the code, and don't route the
creation through operator approval — minting vocabulary is the agent's
call (#2798); an unmodelled area means every record about it stays
call; an unmodelled area means every record about it stays
untaggable. An audit or sweep that walks the codebase is a DISCOVERY
moment: mint the Systems it names as it names them — the duplicate gate
below, plus reviewing the existing list, is what guards against sprawl,
@@ -220,7 +219,7 @@ async def create_system(
tag records to that one, or update_system it if its charter needs work.
Also mapped against the GLOBAL area catalog, so the same word means the
same thing in every project (milestone 307). A name that IS a catalog area
same thing in every project. A name that IS a catalog area
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
the response says so. A name that merely RESEMBLES one comes back with
`canonical_suggestion` — an offer, not a decision: apply it with
@@ -297,7 +296,7 @@ async def get_system(system_id: int) -> dict:
system = await systems_svc.get_system(uid, system_id)
if system is None:
raise ValueError(f"system {system_id} not found")
# The pull half of the rulings arm's measurement (milestone 444): a
# The pull half of the rulings arm's measurement: a
# System whose rulings were shown, then opened.
record_system_pulled(user_id=uid, system_id=system_id, source="mcp_get_system")
records = await systems_svc.list_records_for_system(uid, system_id)
@@ -316,7 +315,7 @@ async def get_system(system_id: int) -> dict:
data["tasks"] = tasks
data["notes"] = notes
# How often this area's rulings were shown to a session and the System
# opened (#4769). Here and not on list_systems: one lookup on a read that
# opened. Here and not on list_systems: one lookup on a read that
# asked for the System, rather than a column on every project handshake.
data["usage"] = (await system_usage_svc.usage_for_systems([system.id]))[system.id]
return data
@@ -412,8 +411,8 @@ async def list_canonical_systems() -> dict:
"""The GLOBAL vocabulary of area names, shared by every project.
These are the standard names to prefer when creating a System, so the same
word means the same thing in every project on the instance — and, from
milestone 307, the ids a cross-project record can point at. A project's own
word means the same thing in every project on the instance — and the ids
a cross-project record can point at. A project's own
System keeps whatever name the project calls the area; mapping it here is
an association, never a rename.
+16 -23
View File
@@ -4,15 +4,9 @@ Tasks are notes with a non-null `status` — same model, different filter.
Wrappers call services/notes.py for CRUD with is_task=True and add the
task-specific fields (status, priority, due_date, parent_id), plus
services/task_logs.py for add_task_log AND for reading those logs back —
`get_task` returns them and `list_tasks` counts them. For most of this
module's life `add_task_log` wrote to a surface no agent could read: the
entries reached the web UI and nothing else, so a session opening a task
saw only the body — a claim written before the work — with the record
written during it invisible beside it. A stale body then had nothing to
contradict it, and shipped work got rebuilt (#4241).
There is no delete_task — matches the existing fable-mcp surface.
Cancel by updating status to "cancelled".
`get_task` returns them and `list_tasks` counts them. The log is the record
written during the work; a body is a claim written before it, so a session
opening a task needs both.
Sentinels (preserved from existing fable-mcp):
- status="" / priority="" / title="" / body="" → "leave unchanged" on update
@@ -181,8 +175,8 @@ async def get_task(
written once, at the start, when the least is known; the log is written
during the work and after it. A task whose body says "not started" and
whose log records a partial ship is not a contradiction to resolve — the
log is simply later. This block exists because its absence cost a session
a day of rebuilding work that had already shipped (#4241).
log is simply later; trusting the body alone is how finished work gets
rebuilt.
The NEWEST entry arrives whole (to 4000 characters); older ones are
shortened to a headline. Anything shortened is cut from the MIDDLE, so
@@ -228,10 +222,9 @@ async def get_task(
# Same reasoning as get_note's record_pulled, and this is the tool where it
# matters MOST: auto-inject ranks kind-blind over a corpus that is
# overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without
# this the surfaced→pulled loop was open exactly where the volume is — every
# surfaced task counted as never-pulled because the tool that opens one didn't
# say so, driving auto-inject's measured pull-through toward zero for its own
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
# this every surfaced task would count as never-pulled, driving
# auto-inject's measured pull-through toward zero for its own dominant
# kind.
await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, data, note.id, note.project_id
)
@@ -365,7 +358,7 @@ async def create_task(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
# A task that says it is "matching" another project's work names the
# source of a pattern — family canon's citation trigger (milestone 463).
# source of a pattern — family canon's citation trigger.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await placement_svc.attach_placement(uid, data, note)
@@ -467,8 +460,8 @@ async def update_task(
await family_svc.attach_family_hint(uid, data, note, created=False)
if status in _CLOSING_STATUSES:
data["report_back"] = REPORT_BACK_CUE
# Owed family adoptions filed while the task was open (milestone 463
# step 6) — work now waiting in some project, which the report names.
# Owed family adoptions filed while the task was open — work now
# waiting in some project, which the report names.
await family_adoption_svc.attach_owed_adoptions(uid, data, note)
return await moment_delivery.attach_moment_rules(
uid, "update_task", {"status": status, "project_id": project_id}, data,
@@ -490,7 +483,7 @@ async def add_task_log(task_id: int, content: str) -> dict:
out, and where it actually stands.
A log on an open task also marks it as being worked by this session — its
`claim` (milestone 381). That is what brings the task and its newest
`claim`. That is what brings the task and its newest
entries back to you after a compaction; it lapses by itself once the
session stops writing, and a status of done, cancelled or todo clears it.
@@ -520,8 +513,8 @@ async def add_task_log(task_id: int, content: str) -> dict:
return data
# The in-band half of milestone 409 step 3. The completion shape itself rides
# the closing response as `reply_shape` (milestone 500); this is the one line
# The in-band completion cue. The completion shape itself rides the closing
# response as `reply_shape`; this is the one line
# that says what the reply covers, for every MCP client, at the moment a piece
# of work closes. One line on purpose: a template here would be read as the
# reply itself.
@@ -537,7 +530,7 @@ _ITEM_KEYS = {"title", "body", "type", "status", "priority", "kind", "tags", "sy
def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc.BatchItem]:
"""Parse the door's plain dicts into BatchItems, refusing unknown keys.
Strict for the same reason StrictArgsMCPServer is (#2709): a misspelt key
Strict for the same reason StrictArgsMCPServer is: a misspelt key
silently dropped — `milestone` for a per-record milestone, `desc` for body —
creates a record that looks right and is missing what the caller sent.
"""
@@ -574,7 +567,7 @@ async def _first_duplicate(
"""The duplicate gate over a whole batch — the first hit blocks all of it.
`overlaps`, when given, collects each record's near matches below the copy
band by its 1-based position (#4306), for the reply once the batch is
band by its 1-based position, for the reply once the batch is
created."""
for i, item in enumerate(items, start=1):
found: list = []
+18 -27
View File
@@ -1,17 +1,11 @@
"""The wide net — every candidate that might govern what you are about to do (#4103).
"""The wide net — every candidate that might govern what you are about to do.
WHY THIS EXISTS
WHY IT IS A PULL
Milestone 416 step 5, from the operator's compromise:
"if you're worried about excluding potentially important data let limit it
to 50 entries or something like that"
Fifty in the PUSH would be milestone 394 with extra steps — a wall the reader
skims, with the governing record indistinguishable from forty-nine others.
Fifty in a PULL is a different object: it arrives only when a session asks, it
crowds nothing out, and it is the honest home for "do not exclude potentially
important data".
Up to fifty candidates pushed unasked would be a wall the reader skims, with
the governing record indistinguishable from forty-nine others. Fifty in a PULL
is a different object: it arrives only when a session asks, it crowds nothing
out, and it is the honest home for "do not exclude potentially important data".
WHY IT IS NOT `search(content_type="rule")`
@@ -26,15 +20,13 @@ to open it. Opposite trade-off, so it is a second tool rather than a bigger
WHY THE TAIL IS TRUNCATED RATHER THAN FULL
Because the step's premise needed correcting. The task said fifty "costs
nothing", and `_rule_hint_line` had already measured otherwise: a line runs
~143 tokens once its trigger is rendered, and #3855 tripled trigger lengths
across the corpus. Fifty of those is ~7,000 tokens — cheap next to an arm that
fires before every Bash call, but not free, and a tool that promises a free
wide net gets reached for casually and then regretted.
Fifty is not free. A line runs to a hundred-odd tokens once its trigger is
rendered, so fifty full lines is several thousand — cheap next to an arm that
fires before every Bash call, but a tool that promises a free wide net gets
reached for casually and then regretted.
So this reuses the graduated shape #3851 measured for the push: the top few
carry their trigger in full, the rest carry a cut of it. TRUNCATED, never
So this uses the push arms' graduated shape: the top few carry their trigger
in full, the rest carry a cut of it. TRUNCATED, never
dropped — the trigger is what lets a reader judge relevance without opening
the record, and a teaser without one is just an id.
"""
@@ -49,9 +41,8 @@ from scribe.services.retrieval_telemetry import record_retrieval
# The telemetry `source`, and a PULL — never added to AMBIENT_SOURCES (those
# are deliveries nobody chose) and never confused with a push arm. Keeping it
# separate is load-bearing right now: the push arms' near-miss distributions
# are the evidence #4121 rests on, and a pull mixed into them would move the
# very numbers that step is arguing from.
# separate keeps the push arms' near-miss distributions about the push arms: a
# pull mixed into them would move the numbers their floors are tuned from.
SOURCE = "wide_net"
# No bar. That is the point of the tool rather than an oversight — the caller
@@ -78,7 +69,7 @@ def _teaser(text: str) -> tuple[str, bool]:
The technique is `plugin_context._goal_line`'s, and the fallback is the
part worth copying: `textwrap.shorten` returns a bare "…" when the string
is one unbroken word longer than the cap, and a raw slice ends mid-word
claiming to be the whole thing (#4036). Kept local rather than shared
claiming to be the whole thing. Kept local rather than shared
because the two callers wrap it in different sentences; if a third appears,
that is the moment to extract it rather than now.
"""
@@ -177,7 +168,7 @@ async def what_might_apply(
# Force, not topic. See the docstring — this is never inferred.
"kind": rule.kind,
# A rule in a rulebook topic is global; one on a project binds
# there alone (milestone 414). Which it is changes how far a
# there alone. Which it is changes how far a
# reader should generalise from it.
"scope": "project" if rule.project_id else "global",
"when_to_apply": trigger,
@@ -186,7 +177,7 @@ async def what_might_apply(
})
# Logged as a pull, with `searched` honoured: a search that never ran must
# not be recorded as a ranker declining (#3765).
# not be recorded as a ranker declining.
record_retrieval(
user_id=uid,
source=SOURCE,
@@ -208,7 +199,7 @@ async def what_might_apply(
"detailed": min(detail, len(candidates)),
# Present so a caller can tell "the corpus offered nothing" from "the
# search never ran" — an empty query, an unavailable embedder and a
# failed query all return zero rows and mean different things (#3670).
# failed query all return zero rows and mean different things.
"searched": bool(report.get("searched", True)),
"open_with": "get_rule(id)",
}