From 23e6ee579bb8e5af9baf0770bbcad506a3951ad8 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 21 Jun 2026 19:35:24 +0200 Subject: [PATCH] feat(content): note(scope='handoff') write-path for role note sections (WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents could not author dev_notes / quick_context / auditor_notes — note() only wrote the journal, so those sections were always empty (the root cause of 'nobody leaves notes'). This adds the write path: note(scope='handoff') routes by role to the section's content model via the apply_structured_note chokepoint (content_type_for_role + TaskService.record_section_note), threaded through the do-server note tool and /api/v1/do/note. ruff/mypy/format clean. WIP checkpoint before a fresh session: no unit tests yet and the obligations (tracing VERB_REQUIREMENTS) are not wired — do not deploy until completed + gated. See the project_notes_mandate_feature memory for the full design and remaining work. --- roboco/api/routes/v1/do.py | 1 + roboco/api/schemas/v1/do.py | 7 ++ roboco/mcp/do_server.py | 13 +++- roboco/services/content_notes.py | 21 +++++ roboco/services/gateway/content_actions.py | 90 +++++++++++++++++++++- roboco/services/task.py | 17 ++++ 6 files changed, 146 insertions(+), 3 deletions(-) diff --git a/roboco/api/routes/v1/do.py b/roboco/api/routes/v1/do.py index e4e26101..cd8c770e 100644 --- a/roboco/api/routes/v1/do.py +++ b/roboco/api/routes/v1/do.py @@ -72,6 +72,7 @@ async def do_note( "what_struggled": body.what_struggled, "next_steps": body.next_steps, }, + section=body.section, ) return envelope_to_response(env, request) diff --git a/roboco/api/schemas/v1/do.py b/roboco/api/schemas/v1/do.py index a4c0f381..e1dd5cd2 100644 --- a/roboco/api/schemas/v1/do.py +++ b/roboco/api/schemas/v1/do.py @@ -65,6 +65,13 @@ class NoteRequest(BaseModel): what_learned: str = "" what_struggled: str = "" next_steps: list[str] | None = None + # handoff scope: the agent's dedicated SECTION fields (dev_notes / + # quick_context / auditor_notes …), free-form per content type — e.g. + # {"summary": "...", "changes": [...]} (developer), {"done": "...", + # "next": "..."} (PM/resumption), {"summary": "...", "severity": "risk"} + # (auditor). Validated by the content model server-side. Omit it to write a + # developer summary straight from ``text``. + section: dict[str, Any] | None = None # List-typed fields tolerate a lone scalar: a single string (or, for # ``options``, a single dict) is wrapped into a one-element list before diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index c4933d1d..2f515cc4 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -195,8 +195,11 @@ def note( what_learned: str = "", what_struggled: str = "", next_steps: list[str] | str | None = None, + section: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Write a journal entry. scope in note|decision|reflect|learning|struggle. + """Write a journal entry, or (scope='handoff') your note SECTION. + + scope in note|decision|reflect|learning|struggle|handoff. ``text`` is always the short summary (one paragraph max). For ``decision`` and ``reflect`` scopes the structured fields are RECOMMENDED — fill what @@ -214,7 +217,12 @@ def note( List-typed fields (``options``, ``consequences``, ``next_steps``) tolerate a lone value — pass either a list or a single item. - Other scopes (note / learning / struggle) just need ``text``. + Other journal scopes (note / learning / struggle) just need ``text``. + + scope='handoff' writes your dedicated SECTION (dev_notes / quick_context / + auditor_notes) instead of the journal: pass ``section={...}`` with the + section's fields (PM/resumption needs done+next; auditor needs + summary+severity), or just ``text`` for a developer summary. """ return _post( "/api/v1/do/note", @@ -232,6 +240,7 @@ def note( "what_learned": what_learned, "what_struggled": what_struggled, "next_steps": next_steps, + "section": section, }, ) diff --git a/roboco/services/content_notes.py b/roboco/services/content_notes.py index e51ff46c..0c6c76b7 100644 --- a/roboco/services/content_notes.py +++ b/roboco/services/content_notes.py @@ -28,6 +28,27 @@ _MIRROR_COLUMN: dict[str, str] = { "resumption": "quick_context", } +# Agent role -> the content type / section it authors via note(scope='handoff'). +# Roles absent here (board / advisory / on-demand) have no dedicated section, so +# a handoff from them is rejected with guidance to use a journal scope instead. +_ROLE_TO_CONTENT_TYPE: dict[str, str] = { + "developer": "developer", + "qa": "qa", + "documenter": "doc", + "pr_reviewer": "pr_review", + "auditor": "auditor", + "cell_pm": "resumption", + "main_pm": "resumption", +} + + +def content_type_for_role(role: str) -> str | None: + """The section content-type a role authors via note(scope='handoff'), or None. + + None means the role has no dedicated note section (board / advisory roles). + """ + return _ROLE_TO_CONTENT_TYPE.get(role) + class _NotesTask(Protocol): notes_structured: dict[str, Any] | None diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index 0ad13328..10c29d79 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -21,8 +21,10 @@ import structlog from roboco.config import settings from roboco.exceptions import GitError from roboco.foundation.policy import communications as _comms +from roboco.foundation.policy.content import ContentValidationError from roboco.foundation.policy.content.validators import reject_trivial from roboco.foundation.policy.journaling import Scope as _Scope +from roboco.services.content_notes import content_type_for_role from roboco.services.gateway.commit_validator import validate_commit_message from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.evidence_builder import build_evidence_for_task @@ -532,8 +534,14 @@ class ContentActions: scope: str = "note", task_id: UUID | None = None, structured: dict[str, Any] | None = None, + section: dict[str, Any] | None = None, ) -> Envelope: - """Write a journal entry. scope ∈ note|decision|reflect|learning|struggle. + """Write a journal entry, or (scope='handoff') the role's note section. + + scope ∈ note|decision|reflect|learning|struggle write the JOURNAL; + scope='handoff' writes the agent's dedicated SECTION (dev_notes / + quick_context / auditor_notes …) from ``section`` (or a summary from + ``text`` when ``section`` is omitted). ``structured`` carries scope-specific fields: @@ -552,6 +560,13 @@ class ContentActions: is taken from ``structured["title"]`` when present, otherwise from the first line of ``text``. """ + if scope == "handoff": + # Section write (dev_notes / quick_context / auditor_notes / …), not + # a journal entry. Content quality is enforced by the content model + # (apply_structured_note), so skip the journal-text soup check. + return await self._record_section_handoff( + agent_id=agent_id, text=text, task_id=task_id, structured=section + ) if rej := self._reject_soup(text, field="note", min_chars=8): return rej if scope not in _VALID_NOTE_SCOPES: @@ -592,6 +607,79 @@ class ContentActions: context_briefing={}, ) + async def _record_section_handoff( + self, + *, + agent_id: UUID, + text: str, + task_id: UUID | None, + structured: dict[str, Any] | None, + ) -> Envelope: + """Write the agent's dedicated note SECTION — the structured-content + counterpart to a journal note. ``note()`` only ever wrote the journal; + this is how a developer / PM / auditor authors dev_notes / quick_context + / auditor_notes (etc.). + + Routes by role to the right content type, persists through the + ``apply_structured_note`` chokepoint, and also drops a journal trail + entry so the write shows in the activity log (and the auditor's + session has a signal). Validation failures return a remediation + Envelope, never a raw 422. + """ + agent = await self.task.agent_for(agent_id) + role = str(agent.role) if agent is not None else "" + content_type = content_type_for_role(role) + if content_type is None: + return Envelope.invalid_state( + message=f"role {role!r} has no dedicated note section", + remediate=( + "only developer / qa / documenter / pr_reviewer / auditor / " + "cell_pm / main_pm author a section — use scope='note' (or " + "decision/reflect/learning/struggle) for a journal entry" + ), + context_briefing={}, + ) + if task_id is not None: + if reject := await self._verify_explicit_task_ownership(agent_id, task_id): + return reject + else: + t = await self.task.get_journal_context_task_for_agent(agent_id) + if t is None: + return Envelope.invalid_state( + message="no task to attach the section note to", + remediate="pass task_id=''", + context_briefing={}, + ) + task_id = t.id + payload: dict[str, Any] = dict(structured) if structured else {"summary": text} + try: + await self.task.record_section_note(task_id, content_type, payload) + except ContentValidationError as exc: + return Envelope.invalid_state( + message=f"section note rejected: {exc.field} — {exc.reason}", + remediate=( + f"provide the section's fields via structured=... for content " + f"type {content_type!r} (resumption needs done+next; auditor " + "needs summary+severity; others need a substantive summary), " + "then retry" + ), + context_briefing={}, + ) + await self.journal.write_entry( + agent_id=agent_id, + task_id=task_id, + scope="note", + title=text.split("\n", 1)[0][:200] if text else f"{content_type} note", + content=text or "(structured section note)", + ) + await self._touch_heartbeat(task_id) + return Envelope.ok( + status="noted", + task_id=str(task_id), + next="continue", + context_briefing={}, + ) + async def pitch( self, *, diff --git a/roboco/services/task.py b/roboco/services/task.py index 27518d77..1b2804ff 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -1455,6 +1455,23 @@ class TaskService(BaseService): ) return result.scalar_one_or_none() + async def record_section_note( + self, task_id: UUID, content_type: str, payload: Any + ) -> None: + """Validate + persist a role's structured section note (dev_notes / + quick_context / auditor_notes / …) through the ``apply_structured_note`` + chokepoint — the only sanctioned writer of the TEXT note columns. + + Raises ``ContentValidationError`` on a malformed payload (the gateway + maps it to a remediation envelope) and ``LookupError`` if the task is + gone. The request's route transaction commits the mutation. + """ + task = await self.get(task_id) + if task is None: + raise LookupError(f"task not found: {task_id}") + apply_structured_note(task, content_type, payload) + await self.session.flush() + async def update( self, task_id: UUID,