mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user