mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -72,6 +72,7 @@ async def do_note(
|
|||||||
"what_struggled": body.what_struggled,
|
"what_struggled": body.what_struggled,
|
||||||
"next_steps": body.next_steps,
|
"next_steps": body.next_steps,
|
||||||
},
|
},
|
||||||
|
section=body.section,
|
||||||
)
|
)
|
||||||
return envelope_to_response(env, request)
|
return envelope_to_response(env, request)
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ class NoteRequest(BaseModel):
|
|||||||
what_learned: str = ""
|
what_learned: str = ""
|
||||||
what_struggled: str = ""
|
what_struggled: str = ""
|
||||||
next_steps: list[str] | None = None
|
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
|
# List-typed fields tolerate a lone scalar: a single string (or, for
|
||||||
# ``options``, a single dict) is wrapped into a one-element list before
|
# ``options``, a single dict) is wrapped into a one-element list before
|
||||||
|
|||||||
+11
-2
@@ -195,8 +195,11 @@ def note(
|
|||||||
what_learned: str = "",
|
what_learned: str = "",
|
||||||
what_struggled: str = "",
|
what_struggled: str = "",
|
||||||
next_steps: list[str] | str | None = None,
|
next_steps: list[str] | str | None = None,
|
||||||
|
section: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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``
|
``text`` is always the short summary (one paragraph max). For ``decision``
|
||||||
and ``reflect`` scopes the structured fields are RECOMMENDED — fill what
|
and ``reflect`` scopes the structured fields are RECOMMENDED — fill what
|
||||||
@@ -214,7 +217,12 @@ def note(
|
|||||||
List-typed fields (``options``, ``consequences``, ``next_steps``) tolerate
|
List-typed fields (``options``, ``consequences``, ``next_steps``) tolerate
|
||||||
a lone value — pass either a list or a single item.
|
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(
|
return _post(
|
||||||
"/api/v1/do/note",
|
"/api/v1/do/note",
|
||||||
@@ -232,6 +240,7 @@ def note(
|
|||||||
"what_learned": what_learned,
|
"what_learned": what_learned,
|
||||||
"what_struggled": what_struggled,
|
"what_struggled": what_struggled,
|
||||||
"next_steps": next_steps,
|
"next_steps": next_steps,
|
||||||
|
"section": section,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ _MIRROR_COLUMN: dict[str, str] = {
|
|||||||
"resumption": "quick_context",
|
"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):
|
class _NotesTask(Protocol):
|
||||||
notes_structured: dict[str, Any] | None
|
notes_structured: dict[str, Any] | None
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import structlog
|
|||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.exceptions import GitError
|
from roboco.exceptions import GitError
|
||||||
from roboco.foundation.policy import communications as _comms
|
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.content.validators import reject_trivial
|
||||||
from roboco.foundation.policy.journaling import Scope as _Scope
|
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.commit_validator import validate_commit_message
|
||||||
from roboco.services.gateway.envelope import Envelope
|
from roboco.services.gateway.envelope import Envelope
|
||||||
from roboco.services.gateway.evidence_builder import build_evidence_for_task
|
from roboco.services.gateway.evidence_builder import build_evidence_for_task
|
||||||
@@ -532,8 +534,14 @@ class ContentActions:
|
|||||||
scope: str = "note",
|
scope: str = "note",
|
||||||
task_id: UUID | None = None,
|
task_id: UUID | None = None,
|
||||||
structured: dict[str, Any] | None = None,
|
structured: dict[str, Any] | None = None,
|
||||||
|
section: dict[str, Any] | None = None,
|
||||||
) -> Envelope:
|
) -> 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:
|
``structured`` carries scope-specific fields:
|
||||||
|
|
||||||
@@ -552,6 +560,13 @@ class ContentActions:
|
|||||||
is taken from ``structured["title"]`` when present, otherwise
|
is taken from ``structured["title"]`` when present, otherwise
|
||||||
from the first line of ``text``.
|
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):
|
if rej := self._reject_soup(text, field="note", min_chars=8):
|
||||||
return rej
|
return rej
|
||||||
if scope not in _VALID_NOTE_SCOPES:
|
if scope not in _VALID_NOTE_SCOPES:
|
||||||
@@ -592,6 +607,79 @@ class ContentActions:
|
|||||||
context_briefing={},
|
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(
|
async def pitch(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1455,6 +1455,23 @@ class TaskService(BaseService):
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
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(
|
async def update(
|
||||||
self,
|
self,
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
|
|||||||
Reference in New Issue
Block a user