feat(lessons): a lesson can be written, and it keeps every incident that taught it (#3731)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 28s

Milestone 385 step 4 — the write path.

ITS OWN TOOL MODULE, not create_note(note_type="lesson"), on the snippet and
process precedent and for the reason that precedent exists: a kind whose value
depends on one field being filled needs a door that ASKS for that field by
name. create_note would take a lesson through a generic body parameter and the
trigger — the whole of why a lesson is findable — would be something the writer
had to know to include.

THE TRIGGER IS REQUIRED, refused rather than flagged. Step 1 left the choice
open. Refusing is right for the same reason create_rule makes enforcement the
deciding question: a lesson with no trigger is not a weaker lesson, it is a
note that will never surface, and nothing downstream can tell the difference —
it saves, reads correctly in every listing, and is silently absent from the one
moment it was written for. A flag is a warning nobody is present to read; the
write path is where the writer still is. The message says SYMPTOM, because
"required" alone produces a topic where a situation was wanted.

The docstring carries the distinction this milestone exists to fix, in a line a
reader can apply: the difference between a lesson and a rule is FORCE, not
importance. If ignoring it would be a mistake it is a rule and needs the
operator's yes; if ignoring it just means someone re-derives it the slow way it
is a lesson, and nobody is bound.

CARDINALITY: a LIST, in notes.data under `taught_by`. The founding example
generalised three incidents into one claim about failure classes no CI lane can
see — generalising across incidents is the shape a good lesson HAS, and
arose_from_id holds one, so a single id keeps the first and drops two while
reading as complete. It lives in `data` rather than a join table for the reason
decision #4157 put the trigger there: a table would settle, for every note kind
at once, whether provenance is multi-valued — a question nothing has measured.
`arose_from_id` is filled only when there is exactly ONE source, because every
surface that renders it renders it as THE origin, and one of three would make
those surfaces state something false.

THE DUPLICATE GATE, which step 4 asked to check: a lesson is judged at a bar
ABOVE the sibling band, not the general 0.90. #2518 measured deliberately
parallel variants at 0.92 on a document that is mostly prose about the thing,
which is exactly a lesson's shape now — so at 0.90 two genuinely different
lessons about one area ("CI cannot see this class of failure") would refuse each
other. Its own constant rather than reusing the snippet's: the two are separate
facts that coincide today, and this number is inherited from a structurally
analogous corpus rather than measured on lessons, of which there are none yet.

Follows canon #2846 including the third registration point it names and this
change would otherwise have missed: get_lesson is in server._READ_ONLY_TOOLS
and the two writers in _WRITE_TOOLS, which test_mcp_auth requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-18 18:19:28 -04:00
co-authored by Claude Opus 5
parent 1361ed7200
commit 1d201d2ff7
6 changed files with 685 additions and 5 deletions
+197
View File
@@ -0,0 +1,197 @@
"""Lesson MCP tools: a transferable insight, retrievable by situation.
Its own module rather than `create_note(note_type="lesson")`, on the precedent
of snippets and processes — and for the reason that precedent exists. A kind
whose value depends on a field being filled needs a door that ASKS for that
field by name. `create_note` would take a lesson through a generic body
parameter, and the trigger — the whole of why a lesson is findable at all —
would be something the writer had to know to include.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import lessons as lessons_svc
from scribe.services import systems as systems_svc
from scribe.mcp.tools import systems as systems_tools
from scribe.services.note_usage import record_pulled
def _to_dict(note) -> dict:
"""A lesson as the tools return it — the composed fields read back out,
not the raw row, so a caller sees the same vocabulary it wrote with."""
return {
"id": note.id,
"title": note.title,
"body": note.body,
"when_to_apply": lessons_svc.lesson_trigger(note),
"learned_from": lessons_svc.lesson_sources(note),
"tags": list(note.tags or []),
"project_id": note.project_id,
"note_type": note.note_type,
"created_at": note.created_at.isoformat() if note.created_at else None,
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
}
async def create_lesson(
what: str,
when_to_apply: str,
insight: str = "",
learned_from: list[int] | None = None,
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
force: bool = False,
) -> dict:
"""Record something you LEARNED, so a later session meets it at the moment
it applies — on this project or any other.
A LESSON OR A RULE? The difference is FORCE, not importance. A rule is
something that must be followed; a lesson is something worth knowing. If
ignoring it would be a mistake, it is a rule (create_rule) and needs the
operator's yes, because a rule binds every future session. If ignoring it
just means someone re-derives it the slow way, it is a lesson — write it
now, and nobody is bound by it.
That distinction is the whole reason this kind exists. Sessions holding a
transferable insight were reaching for create_rule because it was the only
surface that is both global and situation-keyed, and proposing rules for
things that should never have bound anyone.
WHAT A LESSON IS NOT: a shape to copy is a SNIPPET (create_snippet); a
procedure followed start to finish is a PROCESS (create_process); a record
of what happened, findable by topic, is a NOTE (create_note). A lesson is
the claim you would want handed to you in the same situation next time.
`when_to_apply` IS THE RECORD. Everything else is the payload.
A lesson reaches a session by resembling the SITUATION someone is in, never
by topic — that is what separates it from a note, and it is done by putting
the trigger in the title and again at the head of the body, so the document
is dominated by when it applies. A lesson written without one still saves,
still reads correctly in every listing, and will not surface when it is
needed. There is nothing to notice afterwards: it looks exactly like a
lesson that works.
So write the SYMPTOM, in the words the situation will present itself in —
what someone would be seeing, saying or about to do. "A test fails on code
you believe is correct" is a trigger. "Testing" is a topic, and a topic
matches everything and surfaces for nothing.
Args:
what: The insight in one line — the claim itself, as you would say it.
This becomes the title, joined with the trigger.
when_to_apply: The situation this applies in, as a symptom. Required.
insight: The body — what to do, and the incident that taught it.
Write the story here for the reader; it costs the ranking nothing,
because a long body is split into chunks that each still carry the
trigger.
learned_from: Ids of the issues, tasks or notes this was drawn from —
ALL of them. A lesson that generalises three incidents into one
claim is the good case, not the edge case, so this is a list.
tags: Optional tags.
project_id: Where it was learned. Kept as a fact, and it does not limit
reach: a lesson is retrievable from every project (that is the
point of the kind). 0 = none.
system_ids: Systems (subsystems/areas) to file it under.
force: Create even if a near-duplicate exists.
Returns the created lesson. On a near-duplicate, returns the existing id
instead of creating — two lessons about one failure class want to be one
lesson, so update that one rather than adding a second.
"""
uid = current_user_id()
if not when_to_apply or not when_to_apply.strip():
raise ValueError(
"when_to_apply is required: it is how a lesson is found. Say the "
"SYMPTOM — what someone would be seeing, saying or about to do "
"when this applies — not the topic it is about. Without it this "
"record saves, reads correctly, and never surfaces."
)
sources = lessons_svc.normalize_sources(learned_from)
title, body = lessons_svc.lesson_document(
what, when_to_apply, insight, sources,
)
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
is_task=False, note_type=lessons_svc.LESSON_NOTE_TYPE,
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "lesson")
note = await lessons_svc.create_lesson(
uid, what=what, when_to_apply=when_to_apply, insight=insight,
learned_from=sources, tags=tags, project_id=project_id or None,
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
data = _to_dict(note)
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
return data
async def get_lesson(lesson_id: int) -> dict:
"""Fetch one lesson by id, with its trigger and sources read back out."""
uid = current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
raise ValueError(f"lesson {lesson_id} not found")
out = _to_dict(note)
out.update(await access_svc.describe_provenance(uid, note))
# 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).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_lesson")
return out
async def update_lesson(
lesson_id: int,
what: str = "",
when_to_apply: str = "",
insight: str = "",
learned_from: list[int] | None = None,
tags: list[str] | None = None,
) -> dict:
"""Update a lesson. Empty fields are left unchanged.
REWORDING A LESSON IS ORDINARY WORK. Understanding improves, and a trigger
that turned out to fire on the wrong situation is the single most valuable
thing to fix here — a lesson nobody is reaching is usually not wrong, it is
keyed to a situation nobody is in.
Title, body and the indexed mirror are re-composed together from the merged
fields, so a partial update cannot leave the trigger saying one thing in
the title and another in the body.
Args:
lesson_id: Lesson to update.
what: New one-line claim. Empty leaves unchanged.
when_to_apply: New trigger, as a symptom. Empty leaves unchanged.
insight: New body. Empty leaves unchanged.
learned_from: Replace the source ids. None leaves unchanged; pass the
FULL list, including the ones already there.
tags: Replace tags. None leaves unchanged.
"""
uid = current_user_id()
note = await lessons_svc.update_lesson(
uid, lesson_id,
what=what or None,
when_to_apply=when_to_apply or None,
insight=insight or None,
learned_from=learned_from,
tags=tags,
)
if note is None:
raise ValueError(f"lesson {lesson_id} not found")
return _to_dict(note)
def register(mcp) -> None:
for fn in (create_lesson, get_lesson, update_lesson):
mcp.tool(name=fn.__name__)(fn)