feat(content): obligate role note sections like journals

Completes the note(scope='handoff') write-path (WIP 23e6ee57): every role
with a dedicated note section is now obligated to populate it, the same way
journals are obligated.

Obligations (foundation.policy.tracing):
- DEV_NOTES / PR_REVIEWER_NOTES / QUICK_CONTEXT_MIN_CHARS requirements +
  checkers, wired onto i_am_done (dev_notes), delegate (quick_context), and
  pr_pass / pr_fail / post_pr_review (pr_reviewer_notes).
- Fixes a latent bug: the docs-notes checker read dev_notes instead of
  doc_notes (the documenter's section); the i_documented shim now feeds
  doc_notes to match.

Auditor: a session-scoped note obligation on i_am_idle — the auditor owns no
delivery task and has no delivery verb, so it must have recorded an
observation within the window before going idle (JournalService.has_recent_entry).

Write-then-gate: persisted sections (dev_notes / quick_context) are
pre-written by the agent's note(scope='handoff') before the gated verb;
argument-borne sections (doc_notes / pr_reviewer_notes) are checked through a
SimpleNamespace shim, the same pattern qa_notes already uses.

Config: dev/pr_reviewer/quick_context min-chars (40/40/30), panel-tunable.
Plus per-gap remediation hints and full coverage (write-path routing,
ownership, validation->remediation, each obligation, the doc_notes fix).

Full make-quality green: 9777 passed, 95.6% coverage.
This commit is contained in:
Renn F
2026-06-21 20:31:42 +02:00
parent 23e6ee579b
commit 8cf697816f
27 changed files with 847 additions and 60 deletions
+83 -17
View File
@@ -45,7 +45,10 @@ from roboco.services.gateway.remediation import (
hint_for_missing_progress,
hint_for_missing_qa_notes,
hint_for_missing_reflect,
hint_for_short_dev_notes,
hint_for_short_doc_notes,
hint_for_short_pr_reviewer_notes,
hint_for_short_quick_context,
hint_for_unaddressed_acceptance_criteria,
)
@@ -1876,6 +1879,7 @@ class Choreographer:
auto-verify path. It is filtered here and re-asserted by the spec
action's own preconditions.
"""
from roboco.config import settings as _settings
from roboco.foundation.policy import tracing as _tr
has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
@@ -1890,6 +1894,9 @@ class Choreographer:
journal_learning_present=has_learning,
journal_struggle_present=has_struggle,
journal_during_work_count=during_work_count,
# DEV_NOTES_MIN_CHARS reads the persisted task.dev_notes — the dev
# pre-writes it via note(scope='handoff') before i_am_done.
dev_notes_min_chars=_settings.dev_notes_min_chars,
)
requirements: list[_tr.Requirement] = [
r
@@ -2004,7 +2011,15 @@ class Choreographer:
and (datetime.now(UTC) - latest).total_seconds() <= window_seconds
)
ctx = _tr.GateContext(journal_decision_present=fresh)
# QUICK_CONTEXT_MIN_CHARS applies only to ``delegate`` (its
# required-set is the only one carrying it); the PM pre-writes the
# parent's quick_context via note(scope='handoff') before delegating.
# Setting the threshold here is inert for unblock / escalate, which do
# not require it.
ctx = _tr.GateContext(
journal_decision_present=fresh,
quick_context_min_chars=_settings.quick_context_min_chars,
)
result = _tr.check_requirements(
task=t,
requirements=list(_tr.requirements_for(verb)),
@@ -2189,6 +2204,17 @@ class Choreographer:
min_chars=_roboco_settings.docs_notes_min_chars
),
"docs_files_non_empty": hint_for_missing_doc_files(),
"dev_notes>=min": hint_for_short_dev_notes(
min_chars=getattr(_roboco_settings, "dev_notes_min_chars", 40),
task_id=tid,
),
"pr_reviewer_notes>=min": hint_for_short_pr_reviewer_notes(
min_chars=getattr(_roboco_settings, "pr_reviewer_notes_min_chars", 40),
),
"quick_context>=min": hint_for_short_quick_context(
min_chars=getattr(_roboco_settings, "quick_context_min_chars", 30),
task_id=tid,
),
"journal:note_at_claim": (
"pre-gateway parity P1: write a journal:note at claim. "
f"Call note(scope='note', task_id='{tid}', "
@@ -2969,22 +2995,21 @@ class Choreographer:
),
context_briefing=briefing,
)
if guard := await self._pending_assignment_guard(agent_id, briefing):
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
if guard := await self._pm_unfinished_review_guard(agent_id, briefing):
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
if guard := await self._pm_uncovered_decomposition_guard(agent_id, briefing):
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
if guard := await self._pm_uncovered_required_cells_guard(agent_id, briefing):
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
# Pre-idle guards, evaluated in order — the first that returns an
# Envelope short-circuits to a rejection (kept as a loop so adding a
# guard doesn't push this verb over the return-count bound).
idle_guards = (
self._pending_assignment_guard,
self._pm_unfinished_review_guard,
self._pm_uncovered_decomposition_guard,
self._pm_uncovered_required_cells_guard,
self._auditor_note_guard,
)
for guard_fn in idle_guards:
if guard := await guard_fn(agent_id, briefing):
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
paused_ids = await self._auto_pause_in_progress_tasks(agent_id)
await self.task.mark_agent_idle(agent_id)
if paused_ids:
@@ -3108,6 +3133,47 @@ class Choreographer:
context_briefing=briefing,
)
# The auditor must have recorded an observation within this window before
# it may go idle (session-scoped note obligation; see _auditor_note_guard).
_AUDITOR_IDLE_NOTE_WINDOW_SECONDS: int = 3600
async def _auditor_note_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse i_am_idle when the auditor has not recorded an observation.
Every role with a dedicated note section is obligated to populate it,
the same way journals are obligated. The auditor's section is
``auditor_notes``, but it owns no delivery task and has no delivery
verb to hang the obligation on — so the obligation is session-scoped:
before going idle the auditor must have recorded an observation within
the last ``_AUDITOR_IDLE_NOTE_WINDOW_SECONDS`` (a reflect journal entry,
or a note(scope='handoff') section write — either leaves a journal
entry). Inert for every other role.
"""
agent = await self.task.agent_for(agent_id)
if agent is None or str(agent.role) != "auditor":
return None
if await self.journal.has_recent_entry(
agent_id, self._AUDITOR_IDLE_NOTE_WINDOW_SECONDS
):
return None
return Envelope.invalid_state(
message=(
"as the auditor you must record an observation before going "
"idle — your auditor_notes section is the artifact the company "
"reviews, so it cannot be left empty."
),
remediate=(
"call note(scope='reflect', text='<what you observed and any "
"concern>') for a journal observation, or "
"note(scope='handoff', task_id='<task>', section={'summary': "
"'<observation>', 'severity': 'info'|'watch'|'risk'}) to fill a "
"task's auditor_notes — then retry i_am_idle()."
),
context_briefing=briefing,
)
async def _pm_uncovered_decomposition_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
+2 -2
View File
@@ -272,13 +272,13 @@ class DocMixin(_Base):
persisted to the task yet (the spec runner / verb body writes
them via the atomic action / pre-dispatch stamp), so we thread
them through a SimpleNamespace shim with the minimal attributes
the foundation checkers read off the task object (dev_notes +
the foundation checkers read off the task object (doc_notes +
documents see foundation.policy.tracing._check_docs_notes_min_chars
and _check_docs_files_non_empty).
"""
has_reflect = await self.journal.has_reflect_for_task(doc_agent_id, task_id)
task_view = SimpleNamespace(
dev_notes=notes,
doc_notes=notes,
documents=list(files),
)
ctx = _tr.GateContext(
@@ -229,7 +229,9 @@ class PRGateMixin(_Base):
if isinstance(pre, Envelope):
return pre
t, agent, role_str, briefing, spec_ctx = pre
gate = await self._gate_tracing(reviewer_agent_id, task_id, t, role_str, verb)
gate = await self._gate_tracing(
reviewer_agent_id, task_id, t, role_str, verb, notes=notes
)
if gate is not None:
return gate
runner = self._verb_runner()
@@ -332,14 +334,28 @@ class PRGateMixin(_Base):
t: Any,
role_str: str,
verb: str,
*,
notes: str,
) -> Envelope | None:
"""pr_pass / pr_fail require a journal:learning entry (parity with QA)."""
"""pr_pass / pr_fail require a journal:learning entry (parity with QA)
plus a substantive pr_reviewer_notes section.
The section note is the verb's own ``notes`` argument (the review
verdict / issues), not yet persisted to the task, so it is threaded
through a SimpleNamespace shim (the foundation checker reads
``task.pr_reviewer_notes`` same write-then-gate pattern as qa_notes).
"""
from roboco.config import settings as _settings
has_learning = await self.journal.has_learning_for_task(
reviewer_agent_id, task_id
)
ctx = _tr.GateContext(journal_learning_present=has_learning)
ctx = _tr.GateContext(
journal_learning_present=has_learning,
pr_reviewer_notes_min_chars=_settings.pr_reviewer_notes_min_chars,
)
result = _tr.check_requirements(
task=SimpleNamespace(),
task=SimpleNamespace(pr_reviewer_notes=notes),
requirements=list(_tr.requirements_for(verb)),
ctx=ctx,
)
@@ -293,7 +293,7 @@ class PRReviewerMixin(_Base):
verb="post_pr_review",
)
gate = await self._pr_review_tracing_gate(
reviewer_agent_id, task_id, t, role_str
reviewer_agent_id, task_id, t, role_str, body=body
)
if gate is not None:
return gate
@@ -359,15 +359,33 @@ class PRReviewerMixin(_Base):
}
async def _pr_review_tracing_gate(
self, reviewer_agent_id: UUID, task_id: UUID, t: Any, role_str: str
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
*,
body: str,
) -> Envelope | None:
"""post_pr_review requires a journal:learning entry (parity with QA)."""
"""post_pr_review requires a journal:learning entry (parity with QA)
plus a substantive pr_reviewer_notes section.
The section note is the verb's own ``body`` argument (the change
request), not yet persisted to the task, so it is threaded through a
SimpleNamespace shim (the foundation checker reads
``task.pr_reviewer_notes`` same write-then-gate pattern as qa_notes).
"""
from roboco.config import settings as _settings
has_learning = await self.journal.has_learning_for_task(
reviewer_agent_id, task_id
)
ctx = _tr.GateContext(journal_learning_present=has_learning)
ctx = _tr.GateContext(
journal_learning_present=has_learning,
pr_reviewer_notes_min_chars=_settings.pr_reviewer_notes_min_chars,
)
result = _tr.check_requirements(
task=SimpleNamespace(),
task=SimpleNamespace(pr_reviewer_notes=body),
requirements=list(_tr.requirements_for("post_pr_review")),
ctx=ctx,
)