(motir-core) `reinforce_lesson` — the MCP door a hand-run sweep calls, on the `id` `search_lessons` already hands back
The door a hand-run sweep actually calls. search_lessons already hands the caller everything it needs to name a lesson; this is the write that closes the loop.
What to build — the same six-file chain search_lessons took (MOTIR-3480)
motirAiClient.reinforceLesson— the boundary call ontoPOST /v1/lessons/:id/reinforce, mirroringsearchLessons's request/response typing.projectLessonsService.reinforceLesson— the permission assert happens HERE, BEFORE the upstream call, exactly assearchLessonsassertslesson:viewbefore spending the caller's budget.- The
reinforce_lessonMCP tool — argsprojectKey,lessonId,occurrenceRef, optionalnote. Registered inlib/mcp/registry.ts. - The permission + the CLI grant (below).
- The payload exemption doc in
lib/mcp/payloads/exemptions.ts, beside thesearch_lessonsandadd_lessonentries and for the same stated reason: no/api/v1operation returns a lesson. - The tool DESCRIPTION, which is a deliverable and not a label — it must teach the discriminator, because an agent that reinforces on every edit is worse than one that never reinforces at all. It says: call this when an occurrence you just had matched this lesson — whether or not you then widened it — and do NOT call it for an editorial pass with no incident behind it.
⚠️ A NEW permission, not lesson:manage
lesson:manage is the key add_lesson takes and the key retiring a lesson takes — a policy decision that removes a row from every future injection. Reinforcement is its opposite: additive, idempotent, and the thing we want a routine run to do freely. Granting lesson:manage to reach it would hand every dispatched agent the ability to retire. Add lesson:reinforce and grant that.
⚠️ Widening CLI_TOKEN_GRANT is not a one-line edit — it trips FOUR guards. lib/mcp/toolPermissions.ts is only the first; find and satisfy the rest in the same PR rather than discovering them in CI.
Acceptance criteria
reinforce_lessonis registered and callable, takingprojectKey,lessonIdandoccurrenceRef; the lesson id is the onesearch_lessonsreturns, and this card changes nothing aboutsearch_lessonsto obtain it.projectLessonsService.reinforceLessonassertslesson:reinforcebefore the motir-ai call; a caller without it is refused without the upstream ever being reached. Asserted.lesson:reinforceexists as its own key and is inCLI_TOKEN_GRANT;lesson:manageis NOT added to that grant, and a test pins that a CLI-minted token can reinforce and cannot retire.- Every guard that reads
CLI_TOKEN_GRANTis updated in this same PR and the PR body lists them. - The tool description states the discriminator in its own words: an occurrence that just happened is a hit whether or not an edit follows; an editorial pass with no incident is not.
- A replay of the same
occurrenceRefsucceeds and reports that it did not count — the tool surfaces the ledger's answer rather than swallowing it. - The
exemptions.tsentry documents the returned payload, in the register its two siblings use.
Context refs
lib/mcp/tools/searchLessons.ts— the adapter shape:getByKey, the gate placement,toolOk(prose, exempt(...)).lib/mcp/tools/addLesson.ts— the WRITE-side tool shape and itslesson:manageassert.lib/services/projectLessonsService.ts—searchLessons(the assert-before-boundary pattern),toRankedLessonDTO(:162).lib/ai/motirAiClient.ts—searchLessons/RawRankedLesson(:1153).lib/mcp/registry.ts,lib/mcp/toolPermissions.ts(CLI_TOKEN_GRANT),lib/mcp/payloads/exemptions.ts(:70).MOTIR-3480— the six-file precedent this follows.MOTIR-3547(the parent) — the rule, stated once.