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
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:
+49
-56
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user