feat(content): note(scope='handoff') write-path for role note sections (WIP)

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.
This commit is contained in:
Renn F
2026-06-21 19:35:24 +02:00
parent 4e4d25d8f9
commit 23e6ee579b
6 changed files with 146 additions and 3 deletions
+1
View File
@@ -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)
+7
View File
@@ -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
+11 -2
View File
@@ -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,
},
)
+21
View File
@@ -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
+89 -1
View File
@@ -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='<the task whose section you write>'",
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,
*,
+17
View File
@@ -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,