Feature/architectural conventions standard (#243)

* feat(conventions): standard schema models + effective-map merge

* feat(conventions): tree-sitter Python classifier + placement checks

* feat(conventions): TS classifier, hygiene/custom checks, runner + CLI

* feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration

* feat(conventions): repo auto-scan + scaffold draft renderer

* feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore)

* feat(conventions): auto-scaffold on project registration (flag-gated)

* feat(conventions): TaskDescription.constraints + auto-baseline attach

* feat(conventions): ambient architecture-map injection at spawn

* test(conventions): subprocess CLI smoke for the agent-image entrypoint

* feat(conventions): block i_am_done on block-level convention violations

* feat(conventions): block pr_pass on unresolved convention violations

* feat(conventions): surface convention findings into QA evidence

* docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer

* feat(conventions): panel Conventions tab + flag toggle + parity

* test(conventions): end-to-end block, fix, and waiver through the gate

* refactor(conventions): extract pr_pass guards to keep pr_gate under the gate

* style(conventions): format the baseline-constraints attach in task.create

* test(conventions): type-annotate test helpers for the full mypy gate

* build(conventions): ignore types-PyYAML in deptry (mypy-only type stub)

* docs(conventions): document the standard in CLAUDE.md + PM prompt awareness

* fix(conventions): baseline constraints are non-suppressible (dedup-append)

* feat(conventions): scaffold on first workspace clone (threaded workspace)

* feat(conventions): multi-project ambient map for PO/Intake (per-product)

* feat(conventions): persist findings + violations-feed route (migration 044)

* feat(conventions): panel violations feed in the Conventions tab

* test(conventions): intake-spawn mock accepts the ambient layer kwarg

* fix(docker): ollama-init best-effort pull, gate startup on cached models present

A degraded/slow ollama registry made the model manifest re-check fail under
set -e, so ollama-init exited 1 and blocked the orchestrator's
service_completed_successfully gate — taking the whole stack down even though
both models were already cached. Pulls are now best-effort; success is gated on
the models being present, so a flaky registry can't down a cached deployment.

* refactor(content): drop dead TaskDescription.with_baseline_constraints

The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-22 12:37:46 +02:00
committed by GitHub
co-authored by Renn F
parent 01e10ad693
commit 16789c1ca7
76 changed files with 4910 additions and 74 deletions
+110 -14
View File
@@ -1651,20 +1651,19 @@ class Choreographer:
Returns the rejection envelope if any gate fails; None on pass. Shared
by the normal and resume-from-verifying paths so both push.
"""
if rejection := await self._check_tracing_gates(
ctx.agent_id, ctx.task_id, ctx.task
):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._check_submit_qa_field_gates(
ctx.agent_id, ctx.task_id, ctx.task
):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._ensure_branch_pushed(ctx):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._check_quality_gate(ctx):
return await self._reject_i_am_done(ctx, rejection)
if rejection := await self._toolchain_broken_guard(ctx.agent_id, ctx.task):
return await self._reject_i_am_done(ctx, rejection)
guards = (
lambda: self._check_tracing_gates(ctx.agent_id, ctx.task_id, ctx.task),
lambda: self._check_submit_qa_field_gates(
ctx.agent_id, ctx.task_id, ctx.task
),
lambda: self._ensure_branch_pushed(ctx),
lambda: self._check_quality_gate(ctx),
lambda: self._toolchain_broken_guard(ctx.agent_id, ctx.task),
lambda: self._conventions_gate(ctx),
)
for guard in guards:
if rejection := await guard():
return await self._reject_i_am_done(ctx, rejection)
# Pre-gateway parity: persist per-criterion
# status now that all gates have passed. The write runs AFTER the
# verdict so it cannot change i_am_done's rejection behavior.
@@ -1729,6 +1728,103 @@ class Choreographer:
context_briefing={},
)
async def _conventions_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Block i_am_done on unresolved block-level architectural violations.
Also persists the findings for the panel's violations feed — even the
ones that block this submit.
"""
from roboco.config import settings as _settings
if not _settings.conventions_enabled:
return None
result = await self.git.conventions_check_for_task(ctx.agent_id, ctx.task)
await self._record_convention_findings(ctx.task, result)
return self._conventions_rejection(result, ctx.briefing)
@staticmethod
async def _record_convention_findings(task: Any, result: dict[str, Any]) -> None:
"""Persist the task's findings for the feed, best-effort.
Runs in its OWN committed session so a finding that *blocks* the submit
is captured regardless of the verb's transaction outcome.
"""
project_id = getattr(task, "project_id", None)
task_id = getattr(task, "id", None)
if project_id is None or task_id is None:
return
try:
from roboco.db.base import get_session_factory
from roboco.services.conventions import get_conventions_service
factory = get_session_factory()
async with factory() as db:
await get_conventions_service(db).record_findings(
UUID(str(project_id)),
UUID(str(task_id)),
result.get("findings", []),
)
await db.commit()
except Exception as exc:
logger.warning(
"Recording convention findings failed (non-fatal)", error=str(exc)
)
async def _conventions_guard(
self, agent_id: UUID, task: Any, briefing: dict[str, Any]
) -> Envelope | None:
"""Run the conventions validator on the actor's changed files (gated).
A ``block`` finding (a misplaced definition, a lint suppression) or a
validator that could not run returns a rejection with the offending
``file:line`` + fix hint. ``warn`` findings never block. Inert when the
flag is off. Shared by the i_am_done and pr_pass gates.
"""
from roboco.config import settings as _settings
if not _settings.conventions_enabled:
return None
result = await self.git.conventions_check_for_task(agent_id, task)
return self._conventions_rejection(result, briefing)
@staticmethod
def _conventions_rejection(
result: dict[str, Any], briefing: dict[str, Any]
) -> Envelope | None:
"""Turn a validator result into a rejection Envelope, or None to pass."""
if result.get("could_not_run"):
return Envelope.invalid_state(
message=(
"the architectural-conventions validator could not run on "
"your changed files — this blocks rather than passing silently"
),
remediate=(
"the validator failed to analyze the diff (a parse or grammar "
"error). resolve it and call the verb again; if it persists, "
"call i_am_blocked"
),
context_briefing=briefing,
)
blocks = [f for f in result.get("findings", []) if f.get("level") == "block"]
if not blocks:
return None
listing = "\n".join(
f"- {f.get('file')}:{f.get('line')}{f.get('fix_hint')}" for f in blocks
)
return Envelope.invalid_state(
message=(
f"{len(blocks)} architectural-convention violation(s) must be "
"fixed before this can proceed"
),
remediate=(
"place each definition in the module the architecture map assigns "
"it, then commit and call the verb again. if a finding is a false "
"positive, add a waiver to .roboco/conventions.yml in your branch "
"for the PR to review:\n\n" + listing
),
context_briefing=briefing,
)
async def _ensure_branch_pushed(self, ctx: _IAmDoneContext) -> Envelope | None:
"""Push the task branch to origin before it reaches awaiting_qa.
@@ -58,6 +58,11 @@ class ChoreographerHelpers:
) -> Envelope | None:
raise NotImplementedError
async def _conventions_guard(
self, agent_id: UUID, task: Any, briefing: dict[str, Any]
) -> Envelope | None:
raise NotImplementedError
@classmethod
def _free_text_soup(
cls, checks: tuple[tuple[str, Any, int], ...]
@@ -234,17 +234,12 @@ class PRGateMixin(_Base):
)
if gate is not None:
return gate
# A reviewer must not PASS an assembled PR whose suite cannot be run in
# the workspace (interpreter mismatch). pr_fail stays available.
if verb == "pr_pass" and (
toolchain := await self._toolchain_broken_guard(reviewer_agent_id, t)
):
return await self._emit_rejection(
toolchain.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
if verb == "pr_pass":
blocked = await self._pr_pass_blocked(
reviewer_agent_id, task_id, t, role_str, briefing
)
if blocked is not None:
return blocked
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
@@ -271,6 +266,36 @@ class PRGateMixin(_Base):
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _pr_pass_blocked(
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
briefing: dict[str, Any],
) -> Envelope | None:
"""Refuse pr_pass on a broken toolchain or a block-level violation.
A reviewer must not PASS an assembled PR whose suite can't run in the
workspace, or that carries unresolved architectural-convention
violations; pr_fail stays available. Returns the emitted rejection or
None to proceed. Both guards are inert when their flag is off.
"""
guards = (
lambda: self._toolchain_broken_guard(reviewer_agent_id, t),
lambda: self._conventions_guard(reviewer_agent_id, t, briefing),
)
for guard in guards:
rejection = await guard()
if rejection is not None:
return await self._emit_rejection(
rejection.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
)
return None
async def _post_gate_review_to_pr(
self, t: Any, verb: str, reviewer_slug: str, notes: str
) -> None:
+23 -2
View File
@@ -164,7 +164,7 @@ class QAMixin(_Base):
t = await self.task.qa_claim(qa_agent_id, task_id)
await self.task.mark_evidence_inspected(task_id)
ev = await self._build_qa_claim_evidence(t, task_id)
ev = await self._build_qa_claim_evidence(qa_agent_id, t, task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
@@ -173,7 +173,26 @@ class QAMixin(_Base):
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _build_qa_claim_evidence(self, t: Any, task_id: UUID) -> Any:
async def _qa_convention_findings(
self, qa_agent_id: UUID, t: Any
) -> list[dict[str, Any]]:
"""Convention-validator findings on the task's changed files (flag-gated).
Empty when the subsystem is off; a validator that could not run surfaces
a single explicit ``could_not_run`` entry rather than being dropped, so
QA never mistakes a silent failure for a clean diff.
"""
if not settings.conventions_enabled:
return []
result = await self.git.conventions_check_for_task(qa_agent_id, t)
if result.get("could_not_run"):
reason = result.get("reason") or "validator could not run"
return [{"could_not_run": True, "reason": reason}]
return list(result.get("findings", []))
async def _build_qa_claim_evidence(
self, qa_agent_id: UUID, t: Any, task_id: UUID
) -> Any:
"""Assemble the inline evidence payload returned by claim_review.
Bundles files_changed + pr_diff_summary (both from git, the
@@ -194,11 +213,13 @@ class QAMixin(_Base):
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
convention_findings = await self._qa_convention_findings(qa_agent_id, t)
return build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
pr_diff_summary=diff_summary,
convention_findings=convention_findings,
)
async def _verify_qa_owner(