feat(conventions): generalize defaults, backfill old projects, adopt the standard in-repo

Harden the architectural-conventions standard so it works out-of-the-box on
any project and resolves for projects that predate it, and make RoboCo pass
its own gate.

General defaults (apply to every project, not just one with a tuned file):
- The auto-scan excludes test and documentation trees (tests/, docs/) — those
  legitimately define fixtures and aren't enforced code.
- Helper placement seeds at warn, not block: `helper` matches any top-level
  function, too blunt a signal to hard-block a route file's small private glue.
  Misplaced model/route/component stay block; the body-level thin_routes check
  remains the real fat-handler guard.
- thin_routes no longer counts transaction-lifecycle calls (commit/flush/
  refresh) as data access — an explicit `db.commit()` after delegating to a
  service is a valid pattern.
- no_lint_suppressions exempts a small allowlist of structurally-unavoidable
  framework codes (ruff TC001-TC003, pydantic prop-decorator); bare or other
  suppressions still flag.
- CLAUDE.md rule-lifting skips bare common-word tokens that would match
  everywhere (e.g. "commit"), keeping only specific identifiers.
- The ambient prompt block lists only constrained modules and truncates at a
  line boundary with a "+N more" pointer instead of cutting mid-line.

Backfill: the standard previously read the committed file + repo scan from
project.workspace_path, a field only a manual API call set — so an older
project (or one whose workspace was cleared) showed an empty "missing" map no
matter what was pushed. The service now ensures a dedicated, default-branch
read clone on demand (WorkspaceService.ensure_read_clone) and resolves from
it, persisting the resolved path + real HEAD. The panel tab, the spawn-time
ambient block, and the per-task constraints all resolve the committed standard
with no manual setup.

Adopt in-repo: relocate the inline request/response models from the system and
*_live route modules into roboco/api/schemas/ so the codebase passes its own
placement gate, and ship a canonical .roboco/conventions.yml. no_models_in_routes
and modular_cohesion are now clean and enforced at block.

Docs updated across the user guide, the agent-facing RAG standard, the
developer and pr_reviewer role prompts, CLAUDE.md, and the changelog. New unit
tests cover the scan exclusions, helper-warn, the suppression allowlist, the
commit exemption, and the resolve/backfill path; the conventions + project
integration suites pass against Postgres.
This commit is contained in:
Renn F
2026-06-22 18:15:19 +02:00
parent 0c4d1c119f
commit 17ec52d1b7
26 changed files with 637 additions and 260 deletions
+25 -86
View File
@@ -1,14 +1,16 @@
# Architectural conventions for RoboCo. # Architectural conventions for RoboCo.
# Repo-canonical: this file overlays the auto-derived scan, and every consumer
# (the validator, the per-task constraints, the ambient prompt block) reads the
# merged effective map. Edit freely; the panel's Conventions tab round-trips it.
# #
# Convention vs. claude-agent-runway: RoboCo keeps `no_inline_comments` at WARN, # This file overlays the auto-derived scan: every consumer (the validator, the
# not block. The codebase deliberately documents intent in-line and in module / # per-task constraints, the spawn-time ambient block) reads the merged effective
# function docstrings, so comments are welcome and never strand a task; the gate # map, so only project-specific divergences need to live here. The scan already
# only nudges. The runway "no lint suppressions" rule is honoured but currently # excludes tests/ and docs/, maps the backend (roboco/) + frontend (panel/)
# WARN (see the rule note below) until the ~18 existing pydantic-required # layers, and defaults misplaced-helper to warn — so this file is mostly a small
# suppressions migrate to pyproject config, at which point it returns to block. # set of module declarations plus the rule-level policy below.
#
# Posture: BLOCK every boundary the codebase already honors (a model or helper
# in a route, a route in a service, a model/route in a panel component, a hook
# returning JSX — all refused), WARN the boundaries that still carry debt so a
# task is never stranded on pre-existing code.
version: 1 version: 1
languages: languages:
@@ -22,11 +24,11 @@ modules:
forbidden: forbidden:
- route - route
- path: roboco/api - path: roboco/api
purpose: API package wiring — app, deps, middleware, websocket (helpers welcome) purpose: API package wiring — app, deps, middleware, websocket
forbidden: forbidden:
- model - model
- path: roboco/api/routes - path: roboco/api/routes
purpose: HTTP routes only — thin handlers that delegate to services purpose: HTTP routes — thin handlers that delegate to services
forbidden: forbidden:
- model - model
- helper - helper
@@ -34,11 +36,6 @@ modules:
purpose: API request / response schemas purpose: API request / response schemas
forbidden: forbidden:
- route - route
- path: roboco/api/utils
purpose: API-layer helpers / utilities
forbidden:
- route
- component
- path: roboco/mcp/schemas - path: roboco/mcp/schemas
purpose: MCP tool input / output schemas purpose: MCP tool input / output schemas
forbidden: forbidden:
@@ -70,11 +67,6 @@ modules:
forbidden: forbidden:
- component - component
- route - route
- path: panel/src/lib/stores
purpose: client-side state management
forbidden:
- component
- route
- path: panel/src/lib/api - path: panel/src/lib/api
purpose: typed API client purpose: typed API client
forbidden: forbidden:
@@ -84,80 +76,27 @@ modules:
forbidden: forbidden:
- route - route
- component - component
- path: panel/lib
purpose: shared frontend helpers / utilities
forbidden:
- route
- component
# --- Neutralized: tests + docs own no placement rules ------------------------
# The auto-scan treats any dir named api/models/services/utils/schemas/routes
# as an enforceable code module. Under tests/ and docs/ that is wrong: test
# modules legitimately define fixtures, fakes, and helper functions, and docs/
# is the MkDocs site (markdown, never classified). These overrides switch off
# placement there so a test or docs task is never stranded. Longest-prefix wins,
# so each derived sub-path is neutralized explicitly.
- path: tests/unit/api
purpose: tests — any kind may be defined
forbidden: []
- path: tests/unit/api/routes
purpose: tests — any kind may be defined
forbidden: []
- path: tests/unit/api/schemas
purpose: tests — any kind may be defined
forbidden: []
- path: tests/unit/models
purpose: tests — any kind may be defined
forbidden: []
- path: tests/unit/services
purpose: tests — any kind may be defined
forbidden: []
- path: tests/unit/utils
purpose: tests — any kind may be defined
forbidden: []
- path: docs/api
purpose: documentation site — not code
forbidden: []
- path: docs/models
purpose: documentation site — not code
forbidden: []
rules: rules:
# Posture: BLOCK every boundary this codebase already honors (real teeth, zero # Comments are welcome in RoboCo — intent is documented in-code and in
# false-strands), WARN the boundaries that still carry debt so a task is never # docstrings. The rule only nudges (full-line comments are never flagged).
# stranded on pre-existing code. Every placement rule not named here inherits
# the derived BLOCK level — e.g. a route in roboco/services, a model in a panel
# component, or a hook returning JSX is refused outright.
#
# Hygiene
no_lint_suppressions:
# ~18 live in roboco/ today, most pydantic-required (computed_field
# prop-decorator, TC003 runtime types). WARN until they migrate to pyproject
# per-file-ignores / mypy overrides, then this returns to block.
level: warn
no_inline_comments: no_inline_comments:
# RoboCo documents intent in-code; comments are welcome and never block.
level: warn level: warn
# Placement debt — clean these up, then promote back to block # A handful of unavoidable framework suppressions are auto-allowed
no_models_in_routes: # (TC001-003, pydantic prop-decorator); the few remaining inline E402 / E501 /
# Inline request/response models in *_live.py belong in roboco/api/schemas. # arg-type suppressions stay warn until they migrate to pyproject config.
level: warn no_lint_suppressions:
no_helpers_in_routes:
# Local _-prefixed route helpers; tighten once they move to services/utils.
level: warn
# Modularity
modular_cohesion:
# Same root as no_models_in_routes (a file with both a model and a route).
level: warn level: warn
# RoboCo routes deliberately call db.commit() (get_db auto-commit is unreliable
# under BaseHTTPMiddleware); explicit commits no longer count as data access,
# so this stays advisory for the few routes that still read/write directly.
thin_routes: thin_routes:
# RoboCo routes deliberately call db.commit() (get_db auto-commit is
# unreliable under BaseHTTPMiddleware) and the check counts commit as data
# access — so this stays advisory rather than blocking the commit convention.
level: warn level: warn
# A few panel components still fetch inline; extract into hooks, then promote.
thin_components: thin_components:
level: warn level: warn
god_class: # Everything else inherits the derived BLOCK level — no models/helpers in
level: warn # routes, no routes in services, no models/routes in panel components, etc.
custom: [] custom: []
waivers: [] waivers: []
+4 -1
View File
@@ -8,17 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added ### Added
- **Architectural Conventions Standard — a per-project, repo-canonical architecture map that gates where code may live.** Beyond the `make`-style checks (syntax, types, tests), each project can carry a `.roboco/conventions.yml` declaring which definition *kinds* belong in which modules, a toggleable rule set, custom regex rules, and waivers — so an agent can no longer land a Pydantic model inside a router, a helper in a route file, or a lint suppression. A tree-sitter validator CLI (Python + TypeScript) classifies every changed definition and emits findings; a `block`-level finding refuses a developer's `i_am_done` and the in-path PR gate's `pr_pass` with the offending `file:line` and a fix hint, and findings surface in QA's review evidence. The file is auto-scaffolded on first clone, editable from a per-project Conventions tab in the panel, and a false positive is cleared by a waiver committed in the branch and reviewed in the PR. Gated by `ROBOCO_CONVENTIONS_ENABLED` (default off) and fully inert when off. - **Architectural Conventions Standard — a per-project, repo-canonical architecture map that gates where code may live.** Beyond the `make`-style checks (syntax, types, tests), each project can carry a `.roboco/conventions.yml` declaring which definition *kinds* belong in which modules, a toggleable rule set, custom regex rules, and waivers — so an agent can no longer land a Pydantic model inside a router or a lint suppression (a misplaced *helper* — any top-level function — warns rather than blocks). A tree-sitter validator CLI (Python + TypeScript) classifies every changed definition and emits findings; a `block`-level finding refuses a developer's `i_am_done` and the in-path PR gate's `pr_pass` with the offending `file:line` and a fix hint, and findings surface in QA's review evidence. The auto-derived defaults exclude test and documentation trees, count an explicit `db.commit()` in a route as legitimate (not a fat-route violation), and exempt a small allowlist of structurally-unavoidable framework suppressions (ruff `TC001``TC003`, pydantic `prop-decorator`). The committed file and repo scan are read from a dedicated project-level read clone the service ensures on demand — so the standard resolves even for a project created before it existed, with no manual workspace configuration. The file is auto-scaffolded on first clone, editable from a per-project Conventions tab in the panel, and a false positive is cleared by a waiver committed in the branch and reviewed in the PR. Gated by `ROBOCO_CONVENTIONS_ENABLED` (default off) and fully inert when off.
- **Agent runtime toolchain matching — agents build each target project under the Python that project actually requires.** The agent image bakes one interpreter, but the projects RoboCo builds don't all share it, so a self-gate could pass against the wrong runtime. The workspace now resolves each target's Python from its `requires-python` / `.python-version`, provisions the clone with `uv sync --extra dev --python <version>` (fetching the interpreter on demand), and records a `.git/.roboco-toolchain` marker. A guard refuses a developer's `i_am_done`, QA's `pass_review`, and the PR gate's `pr_pass` when the suite cannot be collected under the provisioned interpreter, so "verifying by reading source" can't masquerade as a passing gate. Gated by `ROBOCO_TOOLCHAIN_MATCH_ENABLED` (default off). - **Agent runtime toolchain matching — agents build each target project under the Python that project actually requires.** The agent image bakes one interpreter, but the projects RoboCo builds don't all share it, so a self-gate could pass against the wrong runtime. The workspace now resolves each target's Python from its `requires-python` / `.python-version`, provisions the clone with `uv sync --extra dev --python <version>` (fetching the interpreter on demand), and records a `.git/.roboco-toolchain` marker. A guard refuses a developer's `i_am_done`, QA's `pass_review`, and the PR gate's `pr_pass` when the suite cannot be collected under the provisioned interpreter, so "verifying by reading source" can't masquerade as a passing gate. Gated by `ROBOCO_TOOLCHAIN_MATCH_ENABLED` (default off).
- **Provider overload circuit-break — a persistent model-API overload parks the provider instead of crash-retrying into it.** A sustained 529/500/503 (the SDK already retries transient ones) now trips the same park-and-probe break as a rate limit: the spawn gate queues further work for that provider and a background loop revives it when the overload lifts, instead of respawning the agent straight back into the failure and burning tokens. Gated by `ROBOCO_OVERLOAD_BREAK_ENABLED` (default on). - **Provider overload circuit-break — a persistent model-API overload parks the provider instead of crash-retrying into it.** A sustained 529/500/503 (the SDK already retries transient ones) now trips the same park-and-probe break as a rate limit: the spawn gate queues further work for that provider and a background loop revives it when the overload lifts, instead of respawning the agent straight back into the failure and burning tokens. Gated by `ROBOCO_OVERLOAD_BREAK_ENABLED` (default on).
- **Structured content standard with obligated note sections.** Every agent-authored handoff (developer, QA, documenter, PR-reviewer, auditor, PM resumption) is now a validated structured model persisted as the source of truth, with the legacy text column derived from it through a single chokepoint. An anti-soup guard rejects filler and all-token-noise free-text across the flow and content verbs, structured PR-review findings render a generated GitHub comment, and each role's note section is obligated at its lifecycle transition the way journals already were. - **Structured content standard with obligated note sections.** Every agent-authored handoff (developer, QA, documenter, PR-reviewer, auditor, PM resumption) is now a validated structured model persisted as the source of truth, with the legacy text column derived from it through a single chokepoint. An anti-soup guard rejects filler and all-token-noise free-text across the flow and content verbs, structured PR-review findings render a generated GitHub comment, and each role's note section is obligated at its lifecycle transition the way journals already were.
### Changed ### Changed
- **RoboCo adopts its own architectural standard.** The repo now ships a canonical `.roboco/conventions.yml`, and the inline request/response models that lived in the `system` and `*_live` route modules were relocated to `roboco/api/schemas/` so the codebase passes its own placement gate (`no_models_in_routes` / `modular_cohesion` are now clean and enforced at `block`).
- **RoboCo's own `requires-python` floor is raised to `>=3.13`.** The codebase imports `tomllib` (3.11+) and runs on 3.13; the previous `>=3.10` floor made the toolchain resolver provision the self-hosted build at 3.10, where the suite cannot even be collected. Agent gate containers now also receive the test-database connection, so an agent's `make quality` runs the real, DB-backed suite instead of a coverage-collapsing unit-only subset. - **RoboCo's own `requires-python` floor is raised to `>=3.13`.** The codebase imports `tomllib` (3.11+) and runs on 3.13; the previous `>=3.10` floor made the toolchain resolver provision the self-hosted build at 3.10, where the suite cannot even be collected. Agent gate containers now also receive the test-database connection, so an agent's `make quality` runs the real, DB-backed suite instead of a coverage-collapsing unit-only subset.
### Fixed ### Fixed
- **The conventions standard now resolves for projects created before it existed.** It previously read the committed `.roboco/conventions.yml` and the repo scan from `project.workspace_path` — a field only a manual API call ever set — so an older project (or one whose workspace was cleared) showed an empty "missing" map no matter what was pushed. The service now ensures a dedicated, default-branch read clone on demand and reads from it, persisting the resolved path + HEAD (the backfill). The panel tab, the spawn-time ambient block, and the per-task constraints all resolve the committed standard with no manual setup.
- **The conventions ambient prompt block no longer truncates mid-line.** It now lists only modules that actually constrain a kind, and when the list would exceed its budget it trims at a line boundary with a `+N more` pointer instead of cutting a module in half.
- **The toolchain gate no longer passes silently on an unverifiable workspace.** A `broken` interpreter still blocks; an `unknown` status — the smoke could not confirm the suite is collectable — now emits a warning when the gate proceeds, instead of slipping through unseen. - **The toolchain gate no longer passes silently on an unverifiable workspace.** A `broken` interpreter still blocks; an `unknown` status — the smoke could not confirm the suite is collectable — now emits a warning when the gate proceeds, instead of slipping through unseen.
- **The crypto tests are hermetic.** The Fernet round-trip tests supply their own key instead of depending on `ROBOCO_ENCRYPTION_KEY` in the environment, so they pass in any gate container without the production secret being injected. - **The crypto tests are hermetic.** The Fernet round-trip tests supply their own key instead of depending on `ROBOCO_ENCRYPTION_KEY` in the environment, so they pass in any gate container without the production secret being injected.
- **`ollama-init` is best-effort and gates startup on the models being present**, so a slow or unreachable model registry can no longer down a fully-cached deployment. - **`ollama-init` is best-effort and gates startup on the models being present**, so a slow or unreachable model registry can no longer down a fully-cached deployment.
+2 -2
View File
@@ -377,9 +377,9 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
## Architectural Conventions Standard ## Architectural Conventions Standard
**Per-project architectural standard (default-off).** Beyond the `make`-style gates (which check syntax/types/tests, not *where code lives*), each project can carry a repo-canonical `.roboco/conventions.yml` — an architecture map (which definition *kinds* belong in which modules), a toggleable rule set, custom regex rules, and waivers — so an agent cannot land a Pydantic model defined inside a router, a helper in a route file, or a `# noqa` / `# type: ignore`. Gated by `ROBOCO_CONVENTIONS_ENABLED`; fully inert when off. **Per-project architectural standard (default-off).** Beyond the `make`-style gates (which check syntax/types/tests, not *where code lives*), each project can carry a repo-canonical `.roboco/conventions.yml` — an architecture map (which definition *kinds* belong in which modules), a toggleable rule set, custom regex rules, and waivers — so an agent cannot land a Pydantic model defined inside a router or a `# noqa` / `# type: ignore`. Placement of a *helper* (any top-level function) only **warns** — too blunt to hard-block; `thin_routes` doesn't count an explicit `db.commit()`; and a small allowlist of unavoidable framework suppressions (ruff `TC001``TC003`, pydantic `prop-decorator`) is exempt. Gated by `ROBOCO_CONVENTIONS_ENABLED`; fully inert when off. RoboCo itself ships a canonical `.roboco/conventions.yml`.
**Effective map.** Consumers read the *effective* map — auto-derived defaults (from a repo scan + `BUILTIN_RULES`) overlaid by the committed file — so behaviour is identical whether the file is present, absent, or partial. `ConventionsService` (`roboco/services/conventions.py`) builds it, caches it per `(project, HEAD sha)` in `project_conventions_cache` (migration `043`), renders the per-task baseline constraints + the ambient prompt block, and scaffolds/restores the file via a PR (`GitService.open_conventions_pr`). The schema lives in `roboco/foundation/policy/conventions/` (pure). **Effective map.** Consumers read the *effective* map — auto-derived defaults (from a repo scan + `BUILTIN_RULES`, excluding `tests/`/`docs/` trees) overlaid by the committed file — so behaviour is identical whether the file is present, absent, or partial. `ConventionsService` (`roboco/services/conventions.py`) builds it, caches it per `(project, HEAD sha)` in `project_conventions_cache` (migration `043`), renders the per-task baseline constraints + the ambient prompt block, and scaffolds/restores the file via a PR (`GitService.open_conventions_pr`). The committed file + scan are read from a dedicated project-level **read clone** the service ensures on demand (`WorkspaceService.ensure_read_clone`, pinned to the default branch's HEAD) — the backfill that makes the standard resolve even for a project created before it existed, with no manual `workspace_path`. The schema lives in `roboco/foundation/policy/conventions/` (pure).
**Validator.** A single Python CLI, `python -m roboco.conventions check --root <repo> --files <a> <b> ...` (`roboco/conventions/`), uses tree-sitter (Python + TypeScript grammars, shipped in the agent image) to classify each changed definition and flag forbidden placements + hygiene + custom-rule matches as JSONL findings, after waiver filtering. Precision over recall (it abstains when uncertain so a `block` gate can't false-positive-strand a task) and fail-loud (a validator that cannot run exits 3 so the gate blocks, never silently passes). **Validator.** A single Python CLI, `python -m roboco.conventions check --root <repo> --files <a> <b> ...` (`roboco/conventions/`), uses tree-sitter (Python + TypeScript grammars, shipped in the agent image) to classify each changed definition and flag forbidden placements + hygiene + custom-rule matches as JSONL findings, after waiver filtering. Precision over recall (it abstains when uncertain so a `block` gate can't false-positive-strand a task) and fail-loud (a validator that cannot run exits 3 so the gate blocks, never silently passes).
+2 -2
View File
@@ -102,7 +102,7 @@ The gateway enforces some of these; the rest are convention but failing one of t
5.`note(scope='reflect', task_id=...)` walks through every criterion (gateway-enforced as `journal:reflect`). 5.`note(scope='reflect', task_id=...)` walks through every criterion (gateway-enforced as `journal:reflect`).
6.`open_pr(task_id)` has been called and the response returned a PR number (gateway-enforced via `pr_number` set). 6.`open_pr(task_id)` has been called and the response returned a PR number (gateway-enforced via `pr_number` set).
7.`notes` argument to `i_am_done` is your self-verification summary — what you tested, edge cases considered, anything QA should look at first. 7.`notes` argument to `i_am_done` is your self-verification summary — what you tested, edge cases considered, anything QA should look at first.
8. ✅ Each definition lives in the module the project's architectural map (`.roboco/conventions.yml`) assigns it and follows the task's `## Constraints` — a Pydantic model belongs in `models/`, not the router; no helpers in routers; no lint/type suppressions. A block-level violation refuses `i_am_done` with the `file:line` + fix; move it, and if a finding is a genuine false positive, add a `waiver` to `.roboco/conventions.yml` in your branch for the PR to review. 8. ✅ Each definition lives in the module the project's architectural map (`.roboco/conventions.yml`) assigns it and follows the task's `## Constraints` — a Pydantic model belongs in `models/`, not the router; keep helpers out of routers (advisory — a misplaced helper only *warns*); no lint/type suppressions. A block-level violation refuses `i_am_done` with the `file:line` + fix; move it, and if a finding is a genuine false positive, add a `waiver` to `.roboco/conventions.yml` in your branch for the PR to review.
If any item fails, do not retry `i_am_done`; fix the missing piece first. If any item fails, do not retry `i_am_done`; fix the missing piece first.
@@ -111,7 +111,7 @@ If any item fails, do not retry `i_am_done`; fix the missing piece first.
Beyond placement and hygiene, the Architectural Conventions Standard now enforces MODULARIZATION via a "modularity" AST check family that inspects a definition's body and a file's composition. Write to it from the start — a block-level modularity finding refuses `i_am_done` (and the PR reviewer's `pr_pass`) with the offending `file:line` + a fix hint, and surfaces in QA's `claim_review` evidence as `convention_findings`. The checks are language-aware: a Python/API project carries `thin_routes`; a TypeScript/React project carries `thin_components`; `modular_cohesion` and `god_class` apply to both. Beyond placement and hygiene, the Architectural Conventions Standard now enforces MODULARIZATION via a "modularity" AST check family that inspects a definition's body and a file's composition. Write to it from the start — a block-level modularity finding refuses `i_am_done` (and the PR reviewer's `pr_pass`) with the offending `file:line` + a fix hint, and surfaces in QA's `claim_review` evidence as `convention_findings`. The checks are language-aware: a Python/API project carries `thin_routes`; a TypeScript/React project carries `thin_components`; `modular_cohesion` and `god_class` apply to both.
- **One architectural concern per file (`modular_cohesion`).** A file must own a single concern. Do not define a Pydantic model inside a router, or a schema inside a component — split each concern into its own module (`models/`, `schemas/`, the hook, …). - **One architectural concern per file (`modular_cohesion`).** A file must own a single concern. Do not define a Pydantic model inside a router, or a schema inside a component — split each concern into its own module (`models/`, `schemas/`, the hook, …).
- **Keep route handlers thin (`thin_routes`, Python/API).** A route delegates data access and business logic to a service. It must NOT run its own database access in the route body — no `session.execute`/`query`/`commit`/`add`, no `select()`/`insert()`/`update()`/`delete()`. Move that into the service the route calls. - **Keep route handlers thin (`thin_routes`, Python/API).** A route delegates data access and business logic to a service. It must NOT run its own database access in the route body — no `session.execute`/`query`/`scalars`/`add`, no `select()`/`insert()`/`update()`/`delete()`. Move that into the service the route calls. (An explicit `await db.commit()` to close the unit of work after delegating is fine — transaction-lifecycle calls don't count.)
- **Keep components presentational (`thin_components`, TypeScript/React).** Data fetching (`fetch`/`axios`) and logic belong in a hook, not the component body. The component renders; the hook fetches. - **Keep components presentational (`thin_components`, TypeScript/React).** Data fetching (`fetch`/`axios`) and logic belong in a hook, not the component body. The component renders; the hook fetches.
- **No god classes (`god_class`).** A class past the method-count threshold is doing too much — decompose it along its responsibilities. - **No god classes (`god_class`).** A class past the method-count threshold is doing too much — decompose it along its responsibilities.
+1 -1
View File
@@ -43,7 +43,7 @@ The PR is from an outside contributor: its code is **untrusted**. Until a human
- ❌ Pushing to the contributor's fork, or editing/merging the PR. You review; you never write or merge. - ❌ Pushing to the contributor's fork, or editing/merging the PR. You review; you never write or merge.
- ❌ A trickle of vague comments. Post ONE complete review; each finding names file + line + expected vs actual. - ❌ A trickle of vague comments. Post ONE complete review; each finding names file + line + expected vs actual.
- ❌ Approving without reading the full diff. - ❌ Approving without reading the full diff.
- ❌ Being lax on the architectural standard. Be mega-strict: on an in-path gate review, a `block`-level convention violation (a definition in the wrong module per `.roboco/conventions.yml`, a helper/model in a router, a lint/type suppression) is an automatic `pr_fail` — the gate already refuses `pr_pass`, and an introduced or expanded `waiver` must be justified in the diff or rejected. Hold placement and house-style to the same bar as correctness. - ❌ Being lax on the architectural standard. Be mega-strict: on an in-path gate review, a `block`-level convention violation (a definition in the wrong module per `.roboco/conventions.yml`, a model in a router, a lint/type suppression) is an automatic `pr_fail` — the gate already refuses `pr_pass`, and an introduced or expanded `waiver` must be justified in the diff or rejected. Hold placement and house-style to the same bar as correctness.
- ❌ Letting a non-modular assembled change through. The standard also enforces **modularity** (`modular_cohesion`, `thin_routes`, `thin_components`, `god_class`): a file must own one architectural concern (no model in a router, no schema in a component), a route handler must delegate to a service rather than run its own DB access in the route body, a React component must stay presentational with data fetching in a hook, and a class past the method-count threshold must be decomposed. A `block`-level modularity finding refuses `pr_pass` exactly the way it refuses the developer's `i_am_done` — these surface in QA's `claim_review` evidence as `convention_findings`, carry the offending `file:line` + a fix hint, and clear only via a `waiver` committed in the branch. - ❌ Letting a non-modular assembled change through. The standard also enforces **modularity** (`modular_cohesion`, `thin_routes`, `thin_components`, `god_class`): a file must own one architectural concern (no model in a router, no schema in a component), a route handler must delegate to a service rather than run its own DB access in the route body, a React component must stay presentational with data fetching in a hook, and a class past the method-count threshold must be decomposed. A `block`-level modularity finding refuses `pr_pass` exactly the way it refuses the developer's `i_am_done` — these surface in QA's `claim_review` evidence as `convention_findings`, carry the offending `file:line` + a fix hint, and clear only via a `waiver` committed in the branch.
## When the gateway returns an error ## When the gateway returns an error
+7 -4
View File
@@ -21,15 +21,15 @@ The rules live in a per-project `.roboco/conventions.yml` with four curated part
The validator runs four check families over each changed file: The validator runs four check families over each changed file:
- **Placement** — a definition whose *kind* is forbidden in its module (a model in a router). - **Placement** — a definition whose *kind* is forbidden in its module (a model in a router). A misplaced **model**, **route**, or **component** is `block` by default; a misplaced **helper** only `warn`s — `helper` matches *any* top-level function, too blunt a signal to hard-block a route file's small private glue (the body-level `thin_routes` check is the real fat-handler guard).
- **Hygiene** — the universal, stack-agnostic house-style rules seeded into every project: `no_lint_suppressions` (`block` by default — no `# noqa`, `# type: ignore`, `eslint-disable`) and `no_inline_comments` (`warn`). - **Hygiene** — the universal, stack-agnostic house-style rules seeded into every project: `no_lint_suppressions` (`block` by default — no `# noqa`, `# type: ignore`, `eslint-disable`) and `no_inline_comments` (`warn`). A small allowlist of *structurally unavoidable* framework codes is exempt from `no_lint_suppressions` — ruff's flake8-type-checking codes (`TC001``TC003`, for an import a framework needs at runtime) and pydantic's `prop-decorator` — so the rule keeps its teeth on genuine error-silencing without footgunning every pydantic/FastAPI project. A bare `# noqa` / `# type: ignore` or any other code is still flagged.
- **Custom** — your project-specific regex rules. - **Custom** — your project-specific regex rules.
- **Modularity** — separation-of-concerns judgements the linters are blind to, inspecting a file's *composition* and a definition's *body*, not just its top-level kind: - **Modularity** — separation-of-concerns judgements the linters are blind to, inspecting a file's *composition* and a definition's *body*, not just its top-level kind:
| Rule | Fires when | Default level | | Rule | Fires when | Default level |
|------|-----------|---------------| |------|-----------|---------------|
| `modular_cohesion` | One file mixes architectural concerns (e.g. a model *and* a route *and* a component) | `block` | | `modular_cohesion` | One file mixes architectural concerns (e.g. a model *and* a route *and* a component) | `block` |
| `thin_routes` | A route handler does its own data access (SQLAlchemy `execute`/`commit`/`select`…) instead of delegating to a service | `block` | | `thin_routes` | A route handler does its own data access (SQLAlchemy `execute`/`scalars`/`add`/`select`…) instead of delegating to a service. Transaction-lifecycle calls (`commit`/`flush`/`refresh`) do **not** count — an explicit `await db.commit()` after delegating is a valid pattern | `block` |
| `thin_components` | A React component fetches data in its body instead of through a hook | `block` | | `thin_components` | A React component fetches data in its body instead of through a hook | `block` |
| `god_class` | A class grows past 15 methods (single-responsibility smell) | `warn` | | `god_class` | A class grows past 15 methods (single-responsibility smell) | `warn` |
@@ -38,10 +38,13 @@ The validator runs four check families over each changed file:
## The effective map: defaults, present, absent, or partial ## The effective map: defaults, present, absent, or partial
Consumers never read the raw committed file — they read the **effective map**, so behaviour is identical whether `.roboco/conventions.yml` is present, absent, or partial. `ConventionsService` (`roboco/services/conventions.py`) builds it by auto-deriving a baseline from a repo scan (it infers modules from directory names like `routers/`, `models/`, `services/`, `components/`, `hooks/`; detects languages by file extension; seeds the universal hygiene rules) and then overlaying the committed file on top. The result is cached per `(project, HEAD sha)`. Consumers never read the raw committed file — they read the **effective map**, so behaviour is identical whether `.roboco/conventions.yml` is present, absent, or partial. `ConventionsService` (`roboco/services/conventions.py`) builds it by auto-deriving a baseline from a repo scan (it infers modules from directory names like `routers/`, `models/`, `services/`, `components/`, `hooks/`; **excludes test and documentation trees**`tests/`, `docs/` — since those legitimately define fixtures and aren't enforced code; detects languages by file extension; seeds the universal hygiene rules) and then overlaying the committed file on top. The result is cached per `(project, HEAD sha)`.
That is the load-bearing property: **the standard is enforced even before any file is committed.** A project with no `.roboco/conventions.yml` still gets sensible auto-derived rules and is gated by them. Resilience is built in — a missing file degrades to the auto-derived defaults, and an unparseable file falls back to the last-good cached map (status `degraded`) so the standard is never silently switched off by a typo. That is the load-bearing property: **the standard is enforced even before any file is committed.** A project with no `.roboco/conventions.yml` still gets sensible auto-derived rules and is gated by them. Resilience is built in — a missing file degrades to the auto-derived defaults, and an unparseable file falls back to the last-good cached map (status `degraded`) so the standard is never silently switched off by a typo.
!!! info "Reads come from a dedicated clone — no setup needed for old projects"
The committed file and the repo scan are read from a project-level **read clone** that the service ensures on demand (pinned to the default branch's HEAD), not from any agent's working clone. This is the backfill: a project created long before the standard existed — with no manually-configured workspace path — still resolves its committed `.roboco/conventions.yml` the first time the panel, a spawn, or a task asks for it. There is nothing to wire up.
## The per-project Conventions editor ## The per-project Conventions editor
Each project carries a **Conventions** tab in its edit dialog (panel component `panel/src/components/conventions/conventions-tab.tsx`). From there you manage the whole standard without hand-editing YAML: Each project carries a **Conventions** tab in its edit dialog (panel component `panel/src/components/conventions/conventions-tab.tsx`). From there you manage the whole standard without hand-editing YAML:
+9 -5
View File
@@ -1,6 +1,6 @@
# Architectural Conventions Standard # Architectural Conventions Standard
A per-project, repo-canonical standard for *where code lives*, how a definition is *built*, and basic house-style hygiene — the layer above the `make`-style gates (which check syntax, types, and tests, not placement or structure). It exists so an agent cannot land a model defined inside a router, a route handler that runs its own database access, a helper in a route file, or a lint suppression, even when the code compiles and the tests pass. It enforces the separation of concerns a senior would demand in review, not just linting. A per-project, repo-canonical standard for *where code lives*, how a definition is *built*, and basic house-style hygiene — the layer above the `make`-style gates (which check syntax, types, and tests, not placement or structure). It exists so an agent cannot land a model defined inside a router, a route handler that runs its own database access, or a lint suppression, even when the code compiles and the tests pass (a misplaced *helper* — any top-level function — only warns, since that signal is too blunt to hard-block). It enforces the separation of concerns a senior would demand in review, not just linting.
The standard is gated by `ROBOCO_CONVENTIONS_ENABLED` (default off) and is fully inert when off. The standard is gated by `ROBOCO_CONVENTIONS_ENABLED` (default off) and is fully inert when off.
@@ -8,7 +8,9 @@ The standard is gated by `ROBOCO_CONVENTIONS_ENABLED` (default off) and is fully
Each project carries a repo-canonical `.roboco/conventions.yml`. It is auto-scaffolded into a project's clone the first time the project is worked on, editable from the per-project **Conventions** tab in the panel, and committed like any other repo file. Each project carries a repo-canonical `.roboco/conventions.yml`. It is auto-scaffolded into a project's clone the first time the project is worked on, editable from the per-project **Conventions** tab in the panel, and committed like any other repo file.
Consumers always read the *effective* map: auto-derived defaults (from a repo scan plus the built-in rules) overlaid by the committed file. Behaviour is identical whether the file is present, absent, or partial — a missing file just means "defaults only". Consumers always read the *effective* map: auto-derived defaults (from a repo scan plus the built-in rules) overlaid by the committed file. Behaviour is identical whether the file is present, absent, or partial — a missing file just means "defaults only". The scan excludes test and documentation trees (`tests/`, `docs/`) — those legitimately define fixtures and aren't enforced code.
The committed file and the scan are read from a project-level **read clone** the service ensures on demand (pinned to the default branch's HEAD), not from any agent's working clone — so the standard resolves even for a project created long before it existed, with no manual workspace configuration.
```yaml ```yaml
# .roboco/conventions.yml # .roboco/conventions.yml
@@ -25,9 +27,9 @@ modules:
- path: app/services - path: app/services
purpose: business logic + side effects purpose: business logic + side effects
# Toggle or re-level the built-in rules. # Toggle or re-level the built-in rules (each fires at `warn` or `block`).
rules: rules:
no_models_in_routers: { level: block } # block | warn | off no_models_in_routers: { level: block }
no_inline_comments: { level: warn } no_inline_comments: { level: warn }
# Project-specific regex rules. # Project-specific regex rules.
@@ -59,7 +61,7 @@ It favours precision over recall — it abstains when it cannot classify a defin
Beyond placement and hygiene, the standard enforces modularization with a **modularity** check family. Where placement asks *which module a definition belongs in*, modularity inspects a definition's **body** and a file's **composition** — the structural questions a senior asks in code review: Beyond placement and hygiene, the standard enforces modularization with a **modularity** check family. Where placement asks *which module a definition belongs in*, modularity inspects a definition's **body** and a file's **composition** — the structural questions a senior asks in code review:
- **`modular_cohesion`** (any stack) — a file must own one architectural concern. A file that mixes them (a Pydantic model defined in a router, a schema defined in a component) is a monolith to split. - **`modular_cohesion`** (any stack) — a file must own one architectural concern. A file that mixes them (a Pydantic model defined in a router, a schema defined in a component) is a monolith to split.
- **`thin_routes`** (Python / API) — a route handler must delegate to a service. It may not run its own database access (no `session.execute` / `query` / `commit` / `add`, no `select()` / `insert()` / `update()` / `delete()`) in the route body. - **`thin_routes`** (Python / API) — a route handler must delegate to a service. It may not run its own database access (no `session.execute` / `query` / `scalars` / `add`, no `select()` / `insert()` / `update()` / `delete()`) in the route body. Transaction-lifecycle calls — `commit` / `flush` / `refresh` — do not count: an explicit `await db.commit()` after delegating to a service is a valid pattern.
- **`thin_components`** (TypeScript / React) — a component must stay presentational. Data fetching (`fetch` / `axios`) belongs in a hook, not in the component body. - **`thin_components`** (TypeScript / React) — a component must stay presentational. Data fetching (`fetch` / `axios`) belongs in a hook, not in the component body.
- **`god_class`** (any stack) — a class past a method-count threshold is doing too much; decompose it to keep a single responsibility. - **`god_class`** (any stack) — a class past a method-count threshold is doing too much; decompose it to keep a single responsibility.
@@ -81,6 +83,8 @@ A `warn`-level finding is reported but never blocks.
A false positive is relieved by a **waiver** the developer commits in their branch — so the escape is accountable and reviewed in the PR, not a silent in-code suppression (`# noqa` / `# type: ignore` are themselves hygiene violations the standard flags). Add the waiver to `.roboco/conventions.yml`, commit it, and the finding is filtered on the next check. A false positive is relieved by a **waiver** the developer commits in their branch — so the escape is accountable and reviewed in the PR, not a silent in-code suppression (`# noqa` / `# type: ignore` are themselves hygiene violations the standard flags). Add the waiver to `.roboco/conventions.yml`, commit it, and the finding is filtered on the next check.
The one exception to "suppressions are violations" is a small allowlist of *structurally unavoidable* framework codes that the validator does not flag: ruff's flake8-type-checking codes (`TC001``TC003`, for an import a framework needs at runtime) and pydantic's `prop-decorator`. A bare `# noqa` / `# type: ignore` or any other code is still a finding.
## Panel ## Panel
The per-project **Conventions** tab (in the edit-project dialog) shows the effective architecture map and its health, and offers **Save** (commit an edited map back to the repo via a PR) and **Restore** (re-scaffold the canonical file). The per-project **Conventions** tab (in the edit-project dialog) shows the effective architecture map and its health, and offers **Save** (commit an edited map back to the repo via a PR) and **Restore** (re-scaffold the canonical file).
+4 -1
View File
@@ -276,7 +276,10 @@ async def conventions_ambient_layer(
service = get_conventions_service(session) service = get_conventions_service(session)
blocks: list[str] = [] blocks: list[str] = []
for project in projects: for project in projects:
block = await service.render_ambient_block(project) # Ensure the read clone so the standard resolves even when the project
# has no manually-configured workspace_path (the backfill path).
workspace = await service.resolve_workspace(project)
block = await service.render_ambient_block(project, workspace=workspace)
if not block: if not block:
continue continue
blocks.append( blocks.append(
+6 -2
View File
@@ -473,8 +473,12 @@ async def get_conventions(
"""Return the project's effective conventions map + its current health.""" """Return the project's effective conventions map + its current health."""
project = await _get_project_or_404(get_project_service(db), project_id) project = await _get_project_or_404(get_project_service(db), project_id)
conv = get_conventions_service(db) conv = get_conventions_service(db)
standard = await conv.get_map(project) # Ensure a default-branch read clone once, then read the map + health from
health = await conv.health(project) # it. This is the backfill: a project created before the standard existed
# (no manual workspace_path) still resolves its committed conventions file.
workspace = await conv.resolve_workspace(project)
standard = await conv.get_map(project, workspace=workspace)
health = await conv.health(project, workspace=workspace)
await db.commit() await db.commit()
return ConventionsResponse( return ConventionsResponse(
standard=standard.model_dump(mode="json"), standard=standard.model_dump(mode="json"),
+8 -63
View File
@@ -17,11 +17,10 @@ Phase 5.
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field, model_validator
from sse_starlette import EventSourceResponse from sse_starlette import EventSourceResponse
from roboco.api.deps import ( from roboco.api.deps import (
@@ -30,6 +29,13 @@ from roboco.api.deps import (
get_orchestrator, get_orchestrator,
require_pm_or_above, require_pm_or_above,
) )
from roboco.api.schemas.prompter_live import (
AgentEvent,
LiveConfirmRequest,
LiveMessageRequest,
StartLiveRequest,
StartLiveResponse,
)
from roboco.services.base import NotFoundError, ServiceError, ValidationError from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import get_prompter_service from roboco.services.prompter import get_prompter_service
from roboco.services.prompter_live import get_live_registry from roboco.services.prompter_live import get_live_registry
@@ -62,41 +68,6 @@ def _translate_service_error(e: ServiceError) -> HTTPException:
) )
class StartLiveRequest(BaseModel):
"""Open a live intake chat scoped to a project XOR a product."""
project_id: UUID | None = None
product_id: UUID | None = None
initial_message: str | None = Field(default=None, min_length=1)
@model_validator(mode="after")
def _exactly_one_scope(self) -> StartLiveRequest:
if bool(self.project_id) == bool(self.product_id):
raise ValueError("provide exactly one of project_id / product_id")
return self
class StartLiveResponse(BaseModel):
"""The new session's id — the panel opens its stream and posts messages to it."""
session_id: str
class LiveMessageRequest(BaseModel):
"""The human's message in an active intake chat."""
text: str = Field(..., min_length=1)
class AgentEvent(BaseModel):
"""One normalized event the container relays (mirrors driver.StreamChunk)."""
kind: str
text: str = ""
tool: str = ""
data: dict[str, Any] = Field(default_factory=dict)
@router.post( @router.post(
"/live/start", "/live/start",
response_model=StartLiveResponse, response_model=StartLiveResponse,
@@ -188,32 +159,6 @@ async def stop_live(session_id: str) -> dict[str, bool]:
return {"stopped": True} return {"stopped": True}
class LiveConfirmRequest(BaseModel):
"""Confirm the agent's draft → a task, scoped to exactly one target.
``route`` is which start button the human pressed: ``"board"`` (Board review
& Start PO + HoM review first) or ``"main_pm"`` (Approve & Start straight
to the Main PM).
"""
project_id: UUID | None = None
product_id: UUID | None = None
draft: dict[str, Any]
route: Literal["board", "main_pm"] = "board"
# Set on a board-informed re-draft: confirm updates this existing task in
# place instead of creating a new one. When present, project/product scope
# is taken from the task, so neither is required here.
task_id: UUID | None = None
@model_validator(mode="after")
def _exactly_one_target(self) -> LiveConfirmRequest:
if self.task_id is not None:
return self
if bool(self.project_id) == bool(self.product_id):
raise ValueError("provide exactly one of project_id / product_id")
return self
@router.post("/live/{session_id}/confirm", status_code=status.HTTP_201_CREATED) @router.post("/live/{session_id}/confirm", status_code=status.HTTP_201_CREATED)
async def confirm_live( async def confirm_live(
session_id: str, session_id: str,
+6 -24
View File
@@ -20,10 +20,15 @@ from typing import TYPE_CHECKING, Any
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from sse_starlette import EventSourceResponse from sse_starlette import EventSourceResponse
from roboco.api.deps import get_orchestrator from roboco.api.deps import get_orchestrator
from roboco.api.schemas.secretary_live import (
AgentEvent,
LiveMessageRequest,
StartSecretaryRequest,
StartSecretaryResponse,
)
from roboco.services.prompter_live import get_live_registry from roboco.services.prompter_live import get_live_registry
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -32,29 +37,6 @@ if TYPE_CHECKING:
router = APIRouter() router = APIRouter()
class StartSecretaryRequest(BaseModel):
"""Open a live Secretary chat (optionally with an opening message)."""
initial_message: str | None = Field(default=None, min_length=1)
class StartSecretaryResponse(BaseModel):
session_id: str
class LiveMessageRequest(BaseModel):
text: str = Field(..., min_length=1)
class AgentEvent(BaseModel):
"""One event relayed from the container onto the session stream."""
kind: str
text: str = ""
tool: str = ""
data: dict[str, Any] = Field(default_factory=dict)
@router.post( @router.post(
"/live/start", "/live/start",
response_model=StartSecretaryResponse, response_model=StartSecretaryResponse,
+1 -24
View File
@@ -16,36 +16,13 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from roboco.api.schemas.system import RateLimitEntry, RateLimitListResponse
from roboco.services.gateway.rate_limit_tracker import RateLimitStateTracker from roboco.services.gateway.rate_limit_tracker import RateLimitStateTracker
router = APIRouter() router = APIRouter()
class _CamelModel(BaseModel):
"""Serialize with camelCase aliases so the panel consumes fields directly."""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class RateLimitEntry(_CamelModel):
"""A single provider's active rate-limit state, in the panel's shape."""
provider: str
affected_agents: list[str]
hit_at: str | None
resume_at: str | None
retry_after_seconds: float | None
class RateLimitListResponse(_CamelModel):
"""The envelope the panel's rate-limit store expects: ``{ "entries": [...] }``."""
entries: list[RateLimitEntry]
def _resume_at(hit_at: str | None, retry_after: float | None) -> str | None: def _resume_at(hit_at: str | None, retry_after: float | None) -> str | None:
"""Estimated lift time = hit_at + retry_after, ISO; falls back to hit_at.""" """Estimated lift time = hit_at + retry_after, ISO; falls back to hit_at."""
if not hit_at or retry_after is None: if not hit_at or retry_after is None:
+73
View File
@@ -0,0 +1,73 @@
"""Request / response schemas for the live intake (Prompter) chat bridge.
Moved out of the route module so the HTTP layer stays handler-only and these
models live with the other API schemas (architectural-conventions placement).
"""
from __future__ import annotations
from typing import Any, Literal
from uuid import UUID # noqa: TC003 — pydantic resolves these annotations at runtime
from pydantic import BaseModel, Field, model_validator
class StartLiveRequest(BaseModel):
"""Open a live intake chat scoped to a project XOR a product."""
project_id: UUID | None = None
product_id: UUID | None = None
initial_message: str | None = Field(default=None, min_length=1)
@model_validator(mode="after")
def _exactly_one_scope(self) -> StartLiveRequest:
if bool(self.project_id) == bool(self.product_id):
raise ValueError("provide exactly one of project_id / product_id")
return self
class StartLiveResponse(BaseModel):
"""The new session's id — the panel opens its stream and posts messages to it."""
session_id: str
class LiveMessageRequest(BaseModel):
"""The human's message in an active intake chat."""
text: str = Field(..., min_length=1)
class AgentEvent(BaseModel):
"""One normalized event the container relays (mirrors driver.StreamChunk)."""
kind: str
text: str = ""
tool: str = ""
data: dict[str, Any] = Field(default_factory=dict)
class LiveConfirmRequest(BaseModel):
"""Confirm the agent's draft → a task, scoped to exactly one target.
``route`` is which start button the human pressed: ``"board"`` (Board review
& Start PO + HoM review first) or ``"main_pm"`` (Approve & Start straight
to the Main PM).
"""
project_id: UUID | None = None
product_id: UUID | None = None
draft: dict[str, Any]
route: Literal["board", "main_pm"] = "board"
# Set on a board-informed re-draft: confirm updates this existing task in
# place instead of creating a new one. When present, project/product scope
# is taken from the task, so neither is required here.
task_id: UUID | None = None
@model_validator(mode="after")
def _exactly_one_target(self) -> LiveConfirmRequest:
if self.task_id is not None:
return self
if bool(self.project_id) == bool(self.product_id):
raise ValueError("provide exactly one of project_id / product_id")
return self
+34
View File
@@ -0,0 +1,34 @@
"""Request / response schemas for the live Secretary chat bridge.
Moved out of the route module so the HTTP layer stays handler-only and these
models live with the other API schemas (architectural-conventions placement).
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class StartSecretaryRequest(BaseModel):
"""Open a live Secretary chat (optionally with an opening message)."""
initial_message: str | None = Field(default=None, min_length=1)
class StartSecretaryResponse(BaseModel):
session_id: str
class LiveMessageRequest(BaseModel):
text: str = Field(..., min_length=1)
class AgentEvent(BaseModel):
"""One event relayed from the container onto the session stream."""
kind: str
text: str = ""
tool: str = ""
data: dict[str, Any] = Field(default_factory=dict)
+33
View File
@@ -0,0 +1,33 @@
"""Schemas for the system monitoring endpoints.
Serialized with camelCase aliases so the control panel consumes the fields
directly. Moved out of the route module so the HTTP layer stays handler-only
(architectural-conventions placement).
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class _CamelModel(BaseModel):
"""Serialize with camelCase aliases so the panel consumes fields directly."""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class RateLimitEntry(_CamelModel):
"""A single provider's active rate-limit state, in the panel's shape."""
provider: str
affected_agents: list[str]
hit_at: str | None
resume_at: str | None
retry_after_seconds: float | None
class RateLimitListResponse(_CamelModel):
"""The envelope the panel's rate-limit store expects: ``{ "entries": [...] }``."""
entries: list[RateLimitEntry]
+35 -1
View File
@@ -7,6 +7,7 @@ not) is allowed. Suppression markers are language-scoped.
from __future__ import annotations from __future__ import annotations
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from roboco.foundation.policy.conventions.models import ( from roboco.foundation.policy.conventions.models import (
@@ -27,6 +28,21 @@ _SUPPRESSIONS: dict[str, tuple[str, ...]] = {
"typescript": ("eslint-disable", "ts-ignore", "ts-expect-error"), "typescript": ("eslint-disable", "ts-ignore", "ts-expect-error"),
"tsx": ("eslint-disable", "ts-ignore", "ts-expect-error"), "tsx": ("eslint-disable", "ts-ignore", "ts-expect-error"),
} }
# Suppression codes that are the SANCTIONED escape hatch rather than a silenced
# error: ruff's flake8-type-checking codes (TC001/2/3 — an import a framework
# needs at runtime, e.g. pydantic / SQLAlchemy / FastAPI, cannot move into a
# TYPE_CHECKING block) and pydantic's computed_field ``prop-decorator``. A
# suppression is allowed only when it carries codes AND every one is in this set;
# a bare ``noqa`` / ``type: ignore`` (blanket, no code) or any other code stays a
# finding. Keeps the rule's teeth on genuine error-silencing without footgunning
# the framework-mandated annotations every pydantic project needs.
_ALLOWED_SUPPRESSION_CODES = frozenset({"TC001", "TC002", "TC003", "prop-decorator"})
_NOQA_CODES = re.compile(r"noqa(?::\s*(?P<codes>[A-Za-z0-9, ]+))?")
_TYPE_IGNORE_CODES = re.compile(r"type:\s*ignore(?:\[(?P<codes>[^\]]*)\])?")
_SUPPRESSION_CODE_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = {
"python": (_NOQA_CODES, _TYPE_IGNORE_CODES),
}
_HYGIENE_TEXT: dict[str, tuple[str, str]] = { _HYGIENE_TEXT: dict[str, tuple[str, str]] = {
"no_inline_comments": ( "no_inline_comments": (
"inline comment trailing code — keep narration out of the code", "inline comment trailing code — keep narration out of the code",
@@ -63,11 +79,29 @@ def _comment_findings(
if _is_inline(lines, row, col): if _is_inline(lines, row, col):
out.append(_finding(rel_path, row + 1, "no_inline_comments", standard)) out.append(_finding(rel_path, row + 1, "no_inline_comments", standard))
text = comment.text.decode(errors="replace") if comment.text else "" text = comment.text.decode(errors="replace") if comment.text else ""
if any(marker in text for marker in _SUPPRESSIONS.get(language, ())): if any(
marker in text for marker in _SUPPRESSIONS.get(language, ())
) and not _suppression_allowed(text, language):
out.append(_finding(rel_path, row + 1, "no_lint_suppressions", standard)) out.append(_finding(rel_path, row + 1, "no_lint_suppressions", standard))
return out return out
def _suppression_allowed(text: str, language: str) -> bool:
"""True iff this suppression lists only sanctioned framework-escape codes.
A bare marker (no code) suppresses everything and is never allowed; a
language with no code grammar here (e.g. TypeScript) is never allowed.
"""
codes: list[str] = []
for pattern in _SUPPRESSION_CODE_PATTERNS.get(language, ()):
for match in pattern.finditer(text):
group = match.group("codes")
if group is None:
return False
codes += [c.strip() for c in re.split(r"[,\s]+", group) if c.strip()]
return bool(codes) and all(c in _ALLOWED_SUPPRESSION_CODES for c in codes)
def _is_inline(lines: list[bytes], row: int, col: int) -> bool: def _is_inline(lines: list[bytes], row: int, col: int) -> bool:
if row >= len(lines): if row >= len(lines):
return False return False
+5 -3
View File
@@ -37,16 +37,18 @@ _MAX_CONCERNS_PER_FILE = 1
# SQLAlchemy session methods + 2.0 constructs that signal data access. A route # SQLAlchemy session methods + 2.0 constructs that signal data access. A route
# whose body calls one of these is doing a repository's / service's job. # whose body calls one of these is doing a repository's / service's job.
# Transaction-lifecycle calls (commit / flush / refresh) are deliberately NOT
# here: a thin route legitimately commits the unit of work after delegating to a
# service — an explicit `await db.commit()` in the handler is a common pattern
# (e.g. when middleware-driven auto-commit is unreliable) and must not, on its
# own, count as the route doing data access.
_DB_METHODS = frozenset( _DB_METHODS = frozenset(
{ {
"execute", "execute",
"scalar", "scalar",
"scalars", "scalars",
"commit",
"add", "add",
"add_all", "add_all",
"flush",
"refresh",
"merge", "merge",
"query", "query",
} }
+48 -3
View File
@@ -41,6 +41,13 @@ _IGNORE_DIRS = frozenset(
} }
) )
# Directory trees that are never placement-enforced. Test trees legitimately
# define fixtures, fakes, and helper functions of every kind, and documentation
# trees (e.g. a docs site) are not code at all. A candidate module sitting under
# any of these path segments is dropped from the map so a test or docs task is
# never stranded — this holds for any project, not just RoboCo.
_NON_SOURCE_SEGMENTS = frozenset({"tests", "test", "docs", "__tests__"})
# Directory-name keywords -> (purpose, forbidden definition kinds). Only kinds # Directory-name keywords -> (purpose, forbidden definition kinds). Only kinds
# the classifiers actually emit can ever fire, so extra entries are harmless. # the classifiers actually emit can ever fire, so extra entries are harmless.
_MODULE_PATTERNS: tuple[tuple[frozenset[str], str, tuple[DefinitionKind, ...]], ...] = ( _MODULE_PATTERNS: tuple[tuple[frozenset[str], str, tuple[DefinitionKind, ...]], ...] = (
@@ -85,6 +92,9 @@ _LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "typescript
_IMPERATIVE = re.compile(r"\b(never|don't|do not|avoid|no)\b", re.IGNORECASE) _IMPERATIVE = re.compile(r"\b(never|don't|do not|avoid|no)\b", re.IGNORECASE)
_CODE_SPAN = re.compile(r"`([^`]+)`") _CODE_SPAN = re.compile(r"`([^`]+)`")
_MAX_LIFTED_RULES = 25 _MAX_LIFTED_RULES = 25
# A separator-free, all-lowercase token shorter than this is treated as a common
# word and not lifted into a custom rule (it would match everywhere).
_MIN_SPECIFIC_TOKEN_LEN = 12
def derive_from_scan(root: Path | str) -> ConventionsStandard: def derive_from_scan(root: Path | str) -> ConventionsStandard:
@@ -105,6 +115,15 @@ def derive_from_scan(root: Path | str) -> ConventionsStandard:
# rule to warn per-rule via the panel editor or the committed file. # rule to warn per-rule via the panel editor or the committed file.
_PLACEMENT_DEFAULT: RuleLevel = "block" _PLACEMENT_DEFAULT: RuleLevel = "block"
# 'helper' is the catch-all kind — ANY top-level function classifies as one — so
# a misplaced helper is too weak a signal to hard-block: a route file's small
# private glue would otherwise strand a task. Helper placement therefore seeds at
# warn, while the precise kinds (model / route / component) stay block. A project
# can still promote no_helpers_in_<leaf> to block in .roboco/conventions.yml. The
# fat-handler signal that does have teeth is the body-level thin_routes check.
_SOFT_PLACEMENT_KINDS = frozenset({"helper"})
_SOFT_PLACEMENT_DEFAULT: RuleLevel = "warn"
# Modularity rules — the separation-of-concerns checks that go beyond linting. # Modularity rules — the separation-of-concerns checks that go beyond linting.
# Cohesion + god-class apply to any classifiable project; the body checks are # Cohesion + god-class apply to any classifiable project; the body checks are
# stack-specific (thin routes for Python APIs, thin components for TS/React), so # stack-specific (thin routes for Python APIs, thin components for TS/React), so
@@ -141,7 +160,12 @@ def _seed_rules(modules: list[Module], languages: list[str]) -> dict[str, Rule]:
leaf = module.path.rstrip("/").rsplit("/", 1)[-1] leaf = module.path.rstrip("/").rsplit("/", 1)[-1]
for kind in module.forbidden: for kind in module.forbidden:
name = f"no_{kind}s_in_{leaf}" name = f"no_{kind}s_in_{leaf}"
rules.setdefault(name, Rule(name=name, level=_PLACEMENT_DEFAULT)) level = (
_SOFT_PLACEMENT_DEFAULT
if kind in _SOFT_PLACEMENT_KINDS
else _PLACEMENT_DEFAULT
)
rules.setdefault(name, Rule(name=name, level=level))
if languages: if languages:
for name, any_level in _MODULARITY_ANY.items(): for name, any_level in _MODULARITY_ANY.items():
rules.setdefault(name, Rule(name=name, level=any_level)) rules.setdefault(name, Rule(name=name, level=any_level))
@@ -168,9 +192,13 @@ def _scan_modules(root: Path) -> list[Module]:
spec = _match_module(name) spec = _match_module(name)
if spec is None: if spec is None:
continue continue
rel = (Path(dirpath) / name).relative_to(root).as_posix() rel = (Path(dirpath) / name).relative_to(root)
if _NON_SOURCE_SEGMENTS.intersection(rel.parts):
continue
purpose, forbidden = spec purpose, forbidden = spec
modules.append(Module(path=rel, purpose=purpose, forbidden=list(forbidden))) modules.append(
Module(path=rel.as_posix(), purpose=purpose, forbidden=list(forbidden))
)
return modules return modules
@@ -207,6 +235,21 @@ def _lift_claude_md(root: Path) -> list[CustomRule]:
return rules return rules
def _is_specific_token(token: str) -> bool:
"""Only lift identifiers specific enough not to match everywhere.
A bare common-word token (``commit``, ``triage``) lifted into a regex would
flag every legitimate use of the word pure noise. Keep tokens with a
separator (``.`` / ``_`` / ``/`` / ``-``), any uppercase letter or digit, or
a length that makes an incidental match unlikely.
"""
if any(sep in token for sep in "._/-"):
return True
if any(ch.isupper() or ch.isdigit() for ch in token):
return True
return len(token) >= _MIN_SPECIFIC_TOKEN_LEN
def _rule_from_line(line: str, seen: set[str]) -> CustomRule | None: def _rule_from_line(line: str, seen: set[str]) -> CustomRule | None:
if not _IMPERATIVE.search(line): if not _IMPERATIVE.search(line):
return None return None
@@ -214,6 +257,8 @@ def _rule_from_line(line: str, seen: set[str]) -> CustomRule | None:
if span is None: if span is None:
return None return None
token = span.group(1).strip() token = span.group(1).strip()
if not _is_specific_token(token):
return None
rule_id = _slug(token) rule_id = _slug(token)
if not token or not rule_id or rule_id in seen: if not token or not rule_id or rule_id in seen:
return None return None
+120 -32
View File
@@ -13,6 +13,7 @@ on are pure.
from __future__ import annotations from __future__ import annotations
import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -39,7 +40,7 @@ if TYPE_CHECKING:
from roboco.db.tables import ProjectTable from roboco.db.tables import ProjectTable
_SCAFFOLD_BRANCH = CONVENTIONS_SCAFFOLD_BRANCH _SCAFFOLD_BRANCH = CONVENTIONS_SCAFFOLD_BRANCH
_AMBIENT_CHAR_CAP = 1200 _AMBIENT_CHAR_CAP = 2000
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -63,28 +64,32 @@ class ConventionsHealth:
class ConventionsService(BaseService): class ConventionsService(BaseService):
"""Cache, render, scaffold, and restore a project's conventions standard.""" """Cache, render, scaffold, and restore a project's conventions standard."""
async def get_map(self, project: ProjectTable) -> ConventionsStandard: async def get_map(
self, project: ProjectTable, *, workspace: Path | None = None
) -> ConventionsStandard:
"""Return the effective standard for ``project`` at its current HEAD.""" """Return the effective standard for ``project`` at its current HEAD."""
pid = self._pid(project) pid = self._pid(project)
head = self._head_sha(project) root, head = self._resolve(project, workspace)
cached = await self._cache_get(pid, head) cached = await self._cache_get(pid, head)
if cached is not None: if cached is not None:
return ConventionsStandard.model_validate(cached.effective_map) return ConventionsStandard.model_validate(cached.effective_map)
file_standard, status = self._read_committed_standard(project) file_standard, status = self._read_committed_standard(root)
if status == "degraded": if status == "degraded":
last_good = await self._latest_ok_map(pid) last_good = await self._latest_ok_map(pid)
if last_good is not None: if last_good is not None:
await self._cache_put(pid, head, last_good, status) await self._cache_put(pid, head, last_good, status)
return last_good return last_good
mapping = effective_map(self._derive(project), file_standard) mapping = effective_map(self._derive(root), file_standard)
await self._cache_put(pid, head, mapping, status) await self._cache_put(pid, head, mapping, status)
return mapping return mapping
async def baseline_constraints(self, project: ProjectTable) -> list[str]: async def baseline_constraints(
self, project: ProjectTable, *, workspace: Path | None = None
) -> list[str]:
"""Render the project's block rules + module boundaries as constraints.""" """Render the project's block rules + module boundaries as constraints."""
mapping = await self.get_map(project) mapping = await self.get_map(project, workspace=workspace)
constraints = [ constraints = [
f"Convention (block): {name.replace('_', ' ')}" f"Convention (block): {name.replace('_', ' ')}"
for name, rule in mapping.rules.items() for name, rule in mapping.rules.items()
@@ -98,33 +103,53 @@ class ConventionsService(BaseService):
] ]
return constraints return constraints
async def render_ambient_block(self, project: ProjectTable) -> str: async def render_ambient_block(
"""Render a compact, bounded 'Architectural Standard' prompt block.""" self, project: ProjectTable, *, workspace: Path | None = None
mapping = await self.get_map(project) ) -> str:
lines = [ """Render a compact, bounded 'Architectural Standard' prompt block.
Only modules that actually forbid a kind are listed an unconstrained
module adds no signal. If the module list would exceed the budget it is
truncated at a line boundary with a ``+N more`` pointer (never cut
mid-line), and the block-level rule summary is always kept.
"""
mapping = await self.get_map(project, workspace=workspace)
header = [
"## Architectural Standard", "## Architectural Standard",
"Place each definition in the module that owns its kind:", "Place each definition in the module that owns its kind:",
] ]
for module in mapping.modules: constrained = [m for m in mapping.modules if m.forbidden]
suffix = (
f" — forbidden: {', '.join(module.forbidden)}"
if module.forbidden
else ""
)
lines.append(f"- `{module.path}`: {module.purpose}{suffix}")
block = sorted(n for n, r in mapping.rules.items() if r.level == "block") block = sorted(n for n, r in mapping.rules.items() if r.level == "block")
if block: footer = ["Block-level rules: " + ", ".join(block) + "."] if block else []
lines.append("Block-level rules: " + ", ".join(block) + ".")
text = "\n".join(lines) # Reserve room for the fixed header/footer plus a possible '+N more' line
if len(text) > _AMBIENT_CHAR_CAP: # so the budget is spent on whole module lines.
return text[: _AMBIENT_CHAR_CAP - 1].rstrip() + "" reserve = len("\n".join(header + footer)) + 80
return text budget = max(0, _AMBIENT_CHAR_CAP - reserve)
kept: list[str] = []
used = 0
for module in constrained:
line = (
f"- `{module.path}`: {module.purpose} "
f"— forbidden: {', '.join(module.forbidden)}"
)
if used + len(line) + 1 > budget:
break
kept.append(line)
used += len(line) + 1
if len(kept) < len(constrained):
extra = len(constrained) - len(kept)
plural = "s" if extra != 1 else ""
kept.append(
f"- (+{extra} more module{plural} — see .roboco/conventions.yml)"
)
return "\n".join(header + kept + footer)
async def scaffold( async def scaffold(
self, project: ProjectTable, *, workspace: Path | None = None self, project: ProjectTable, *, workspace: Path | None = None
) -> ScaffoldResult: ) -> ScaffoldResult:
"""Open a PR adding the auto-scaffolded ``.roboco/conventions.yml``.""" """Open a PR adding the auto-scaffolded ``.roboco/conventions.yml``."""
mapping = await self.get_map(project) mapping = await self.get_map(project, workspace=workspace)
return await self._publish( return await self._publish(
project, render_yaml(mapping), restore=False, workspace=workspace project, render_yaml(mapping), restore=False, workspace=workspace
) )
@@ -134,7 +159,11 @@ class ConventionsService(BaseService):
) -> ScaffoldResult: ) -> ScaffoldResult:
"""Open a PR re-committing the file from the last-good map (or a scan).""" """Open a PR re-committing the file from the last-good map (or a scan)."""
last_good = await self._latest_ok_map(self._pid(project)) last_good = await self._latest_ok_map(self._pid(project))
mapping = last_good if last_good is not None else self._derive(project) if last_good is not None:
mapping = last_good
else:
root, _ = self._resolve(project, workspace)
mapping = self._derive(root)
return await self._publish( return await self._publish(
project, render_yaml(mapping), restore=True, workspace=workspace project, render_yaml(mapping), restore=True, workspace=workspace
) )
@@ -151,10 +180,12 @@ class ConventionsService(BaseService):
project, render_yaml(standard), restore=False, workspace=workspace project, render_yaml(standard), restore=False, workspace=workspace
) )
async def health(self, project: ProjectTable) -> ConventionsHealth: async def health(
self, project: ProjectTable, *, workspace: Path | None = None
) -> ConventionsHealth:
"""Report the standard's status at HEAD + the last-good commit SHA.""" """Report the standard's status at HEAD + the last-good commit SHA."""
pid = self._pid(project) pid = self._pid(project)
head = self._head_sha(project) _root, head = self._resolve(project, workspace)
current = await self._cache_get(pid, head) current = await self._cache_get(pid, head)
last_ok = await self._latest_ok_row(pid) last_ok = await self._latest_ok_row(pid)
return ConventionsHealth( return ConventionsHealth(
@@ -233,14 +264,71 @@ class ConventionsService(BaseService):
path = Path(project.workspace_path) path = Path(project.workspace_path)
return path if path.exists() else None return path if path.exists() else None
def _derive(self, project: ProjectTable) -> ConventionsStandard: async def resolve_workspace(self, project: ProjectTable) -> Path | None:
root = self._workspace_root(project) """Ensure (clone / refresh) the project's read clone; None if unavailable."""
from roboco.services.workspace import get_workspace_service
try:
return await get_workspace_service(self.session).ensure_read_clone(
project.slug
)
except Exception as exc:
self.log.warning(
"conventions: read-clone unavailable; falling back to workspace_path",
project=getattr(project, "slug", None),
error=str(exc),
)
return None
def _resolve(
self, project: ProjectTable, workspace: Path | None
) -> tuple[Path | None, str]:
"""Resolve the repo root to read the standard from, plus its HEAD sha.
Uses an explicit ``workspace`` (the read clone the caller resolved via
:meth:`resolve_workspace`) when given, else the legacy persisted
``workspace_path``. When a clone is resolved, its path + real HEAD are
persisted back onto the project the backfill so the next read (and
the cache key) reflect the committed standard, even for a project
created before the standard existed.
"""
root: Path | None = None
if workspace is not None and Path(workspace).exists():
root = Path(workspace)
else:
root = self._workspace_root(project)
if root is None:
return None, self._head_sha(project)
sha = self._head_sha_at(root)
project.workspace_path = str(root)
if sha is not None:
# Only a real rev-parse (an actual git clone) updates the cache key;
# a non-git legacy path keeps the persisted head_commit / "HEAD".
project.head_commit = sha
return root, sha or self._head_sha(project)
@staticmethod
def _head_sha_at(root: Path) -> str | None:
"""The clone's HEAD sha, or None if ``root`` is not a readable git repo."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=str(root),
capture_output=True,
text=True,
check=False,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout.strip() or None
def _derive(self, root: Path | None) -> ConventionsStandard:
return derive_from_scan(root) if root is not None else ConventionsStandard() return derive_from_scan(root) if root is not None else ConventionsStandard()
def _read_committed_standard( def _read_committed_standard(
self, project: ProjectTable self, root: Path | None
) -> tuple[ConventionsStandard | None, str]: ) -> tuple[ConventionsStandard | None, str]:
root = self._workspace_root(project)
if root is None: if root is None:
return None, "missing" return None, "missing"
path = root / ".roboco" / "conventions.yml" path = root / ".roboco" / "conventions.yml"
+3 -3
View File
@@ -703,9 +703,9 @@ class TaskService(BaseService):
if project is None: if project is None:
return [] return []
try: try:
return await get_conventions_service(self.session).baseline_constraints( conv = get_conventions_service(self.session)
project workspace = await conv.resolve_workspace(project)
) return await conv.baseline_constraints(project, workspace=workspace)
except Exception as exc: except Exception as exc:
self.log.warning( self.log.warning(
"Baseline-constraints attach failed (non-fatal)", "Baseline-constraints attach failed (non-fatal)",
+68
View File
@@ -192,6 +192,12 @@ _ENSURE_WORKSPACE_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
# clone) is attempted at most once per project per process, even across agents. # clone) is attempted at most once per project per process, even across agents.
_SCAFFOLD_ATTEMPTED: set[str] = set() _SCAFFOLD_ATTEMPTED: set[str] = set()
# Project-level read clones (the conventions standard) refresh at most this often
# on a healthy clone, so a burst of spawns / task creations doesn't fetch on
# every call. Keyed by workspace path; process-wide.
_READ_CLONE_FETCH_TTL_SECONDS = 30.0
_read_clone_synced: dict[str, float] = {}
def _ensure_lock_for(project_slug: str, agent_slug: str) -> asyncio.Lock: def _ensure_lock_for(project_slug: str, agent_slug: str) -> asyncio.Lock:
"""Return the asyncio.Lock for a (project, agent) pair, creating lazily.""" """Return the asyncio.Lock for a (project, agent) pair, creating lazily."""
@@ -770,6 +776,68 @@ class WorkspaceService:
error=str(exc), error=str(exc),
) )
async def ensure_read_clone(self, project_slug: str) -> Path:
"""Ensure a project-level read clone pinned to the default branch's HEAD.
The architectural-conventions standard is read from the committed
``.roboco/conventions.yml`` plus a scan of the repo tree. Per-agent
working clones are the wrong source one may sit on a feature branch or
be stale so metadata reads use this dedicated clone instead. It is
never mounted into an agent container and is always hard-reset to
``origin/<default_branch>``, which makes destructive refresh safe. This
is what lets the standard work for a project created before the standard
existed: no manually-configured ``workspace_path`` is required.
"""
from roboco.services.project import get_project_service
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project:
raise WorkspaceError(f"Project not found: {project_slug}")
default_branch = project.default_branch or "master"
git_url = project.git_url
workspace = self.root / project_slug / "_meta" / "conventions"
lock = _ensure_lock_for(project_slug, "_meta-conventions")
async with lock:
if self._is_workspace_healthy(workspace):
now = _monotonic()
last = _read_clone_synced.get(str(workspace), -math.inf)
if (now - last) >= _READ_CLONE_FETCH_TTL_SECONDS:
await asyncio.to_thread(self._prune_broken_refs, workspace)
await self._fetch_origin_best_effort(workspace, project_slug)
await asyncio.to_thread(
self._reset_to_default, workspace, default_branch
)
_read_clone_synced[str(workspace)] = _monotonic()
return workspace
if workspace.exists():
shutil.rmtree(workspace)
git_token = await self._resolve_git_token(
project_service, project_slug, git_url
)
await self._clone_repo(
workspace, git_url, default_branch, git_token, agent=None
)
_read_clone_synced[str(workspace)] = _monotonic()
return workspace
@staticmethod
def _reset_to_default(workspace: Path, default_branch: str) -> None:
"""Hard-reset the read clone to ``origin/<default_branch>``. Best-effort."""
def _git(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=str(workspace),
capture_output=True,
text=True,
check=False,
)
_git("checkout", default_branch)
_git("reset", "--hard", f"origin/{default_branch}")
async def _clone_repo( async def _clone_repo(
self, self,
workspace: Path, workspace: Path,
+1 -1
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from roboco.api.routes import secretary_live as sl from roboco.api.routes import secretary_live as sl
from roboco.api.routes.secretary_live import ( from roboco.api.schemas.secretary_live import (
AgentEvent, AgentEvent,
LiveMessageRequest, LiveMessageRequest,
StartSecretaryRequest, StartSecretaryRequest,
+35
View File
@@ -59,6 +59,41 @@ def test_python_marker_not_applied_to_typescript() -> None:
assert _rules(findings, "no_lint_suppressions") == [] assert _rules(findings, "no_lint_suppressions") == []
def _src(line: str) -> bytes:
# Build via a variable so a literal suppression marker never sits on this
# test file's own source line (ruff would parse it as a real directive).
return (line + "\n").encode()
def test_runtime_typing_noqa_is_allowed() -> None:
# A runtime-needed typing import (pydantic / SQLAlchemy) — the sanctioned
# escape, not error-silencing.
findings = check_hygiene(
"a.py", _src("from uuid import UUID # noqa: TC003"), "python", _STD
)
assert _rules(findings, "no_lint_suppressions") == []
def test_pydantic_prop_decorator_ignore_is_allowed() -> None:
findings = check_hygiene(
"a.py", _src("y = f() # type: ignore[prop-decorator]"), "python", _STD
)
assert _rules(findings, "no_lint_suppressions") == []
def test_other_ignore_code_is_still_flagged() -> None:
findings = check_hygiene(
"a.py", _src("x = bad() # type: ignore[arg-type]"), "python", _STD
)
assert _rules(findings, "no_lint_suppressions")
def test_mixed_allowed_and_disallowed_codes_is_flagged() -> None:
# One allowed code does not launder a disallowed one alongside it.
findings = check_hygiene("a.py", _src("x = 1 # noqa: TC003, E501"), "python", _STD)
assert _rules(findings, "no_lint_suppressions")
def test_rule_level_override_from_standard() -> None: def test_rule_level_override_from_standard() -> None:
std = ConventionsStandard( std = ConventionsStandard(
rules={"no_inline_comments": Rule(name="no_inline_comments", level="block")} rules={"no_inline_comments": Rule(name="no_inline_comments", level="block")}
+15
View File
@@ -77,6 +77,21 @@ def test_thin_routes_clean_when_delegating_to_a_service() -> None:
assert "thin_routes" not in rules assert "thin_routes" not in rules
def test_thin_routes_allows_explicit_commit_in_route() -> None:
# Committing the unit of work after delegating is a common, valid pattern —
# a bare `db.commit()` must not count as the route doing data access.
rules = _py(
"from fastapi import APIRouter\n"
"router = APIRouter()\n"
"@router.post('/users')\n"
"async def create_user(svc, db):\n"
" user = await svc.create()\n"
" await db.commit()\n"
" return user\n"
)
assert "thin_routes" not in rules
# --- Thin components: data fetching belongs in a hook ----------------------- # # --- Thin components: data fetching belongs in a hook ----------------------- #
+26 -2
View File
@@ -48,11 +48,35 @@ def test_scan_ignores_vendored_directories(tmp_path: Path) -> None:
def test_scan_lifts_claude_md_imperative_into_custom_rule(tmp_path: Path) -> None: def test_scan_lifts_claude_md_imperative_into_custom_rule(tmp_path: Path) -> None:
_sample_repo(tmp_path) _sample_repo(tmp_path)
(tmp_path / "CLAUDE.md").write_text("- Never use `print()`; use the logger.\n") (tmp_path / "CLAUDE.md").write_text("- Never call `os.system()`; use subprocess.\n")
custom = derive_from_scan(tmp_path).custom custom = derive_from_scan(tmp_path).custom
assert custom assert custom
assert custom[0].level == "warn" assert custom[0].level == "warn"
assert "print" in custom[0].pattern assert custom[0].id == "os-system"
def test_scan_skips_bare_common_word_in_claude_md(tmp_path: Path) -> None:
# A bare word like `commit` would match everywhere; it must not be lifted.
_sample_repo(tmp_path)
(tmp_path / "CLAUDE.md").write_text("- Never `commit` straight to master.\n")
assert derive_from_scan(tmp_path).custom == []
def test_scan_excludes_test_and_docs_trees(tmp_path: Path) -> None:
(tmp_path / "tests" / "unit" / "services").mkdir(parents=True)
(tmp_path / "docs" / "api").mkdir(parents=True)
(tmp_path / "app" / "services").mkdir(parents=True)
paths = {m.path for m in derive_from_scan(tmp_path).modules}
assert "app/services" in paths
assert not any(p.startswith(("tests/", "docs/")) for p in paths)
def test_scan_seeds_helper_placement_as_warn(tmp_path: Path) -> None:
_sample_repo(tmp_path)
std = derive_from_scan(tmp_path)
# A misplaced model is a hard error; a misplaced helper only warns.
assert std.rules["no_models_in_routers"].level == "block"
assert std.rules["no_helpers_in_routers"].level == "warn"
def test_render_yaml_round_trips_through_parse(tmp_path: Path) -> None: def test_render_yaml_round_trips_through_parse(tmp_path: Path) -> None:
@@ -0,0 +1,66 @@
"""ConventionsService root/HEAD resolution + backfill persistence (no DB)."""
from __future__ import annotations
import subprocess
from types import SimpleNamespace
from typing import TYPE_CHECKING
from roboco.services.conventions import ConventionsService
if TYPE_CHECKING:
from pathlib import Path
def _git_repo(root: Path) -> str:
(root / "roboco" / "services").mkdir(parents=True)
(root / "roboco" / "services" / "x.py").write_text("def f():\n return 1\n")
for cmd in (
["git", "init", "-q"],
["git", "add", "-A"],
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"],
):
subprocess.run(cmd, cwd=root, check=True, capture_output=True)
return subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=root,
capture_output=True,
text=True,
check=False,
).stdout.strip()
def _svc() -> ConventionsService:
return ConventionsService(session=None) # type: ignore[arg-type]
def test_resolve_reads_clone_head_and_backfills(tmp_path: Path) -> None:
sha = _git_repo(tmp_path)
project = SimpleNamespace(workspace_path=None, head_commit=None, slug="p")
root, head = _svc()._resolve(project, tmp_path)
assert root == tmp_path
assert head == sha
# The backfill: the resolved path + real HEAD are persisted on the project.
assert project.workspace_path == str(tmp_path)
assert project.head_commit == sha
def test_resolve_non_git_path_keeps_persisted_head(tmp_path: Path) -> None:
project = SimpleNamespace(
workspace_path=str(tmp_path), head_commit="deadbeef", slug="p"
)
_root, head = _svc()._resolve(project, None)
# A non-git legacy path must not clobber the persisted head_commit.
assert head == "deadbeef"
assert project.head_commit == "deadbeef"
def test_resolve_no_workspace_returns_none_root() -> None:
project = SimpleNamespace(workspace_path=None, head_commit=None, slug="p")
root, head = _svc()._resolve(project, None)
assert root is None
assert head == "HEAD"
def test_head_sha_at_non_git_returns_none(tmp_path: Path) -> None:
assert ConventionsService._head_sha_at(tmp_path) is None