diff --git a/Makefile b/Makefile
index c9c72c2d..c189fffc 100644
--- a/Makefile
+++ b/Makefile
@@ -277,6 +277,17 @@ quality-fast:
@uv run mypy roboco/
@uv run pytest -q -x --no-cov
+# Fast pre-submit gate: format-check + lint + types + complexity, NO tests.
+# This is the command a project points `quality_command` at, so the agent
+# pre-submit gate (run at i_am_done) executes it in the dev's workspace and
+# catches lint/type/complexity at the desk. The test suite stays on CI.
+.PHONY: gate
+gate:
+ @uv run ruff format --check .
+ @uv run ruff check .
+ @uv run mypy roboco/
+ @uv run xenon --max-absolute B --max-modules A --max-average A roboco/
+
# Run all analysis tools
.PHONY: analysis
analysis: deptry
diff --git a/alembic/versions/029_project_quality_command.py b/alembic/versions/029_project_quality_command.py
new file mode 100644
index 00000000..712d3ee0
--- /dev/null
+++ b/alembic/versions/029_project_quality_command.py
@@ -0,0 +1,32 @@
+"""Add projects.quality_command — the fast pre-submit gate command.
+
+When set, the agent pre-submit gate (run at a developer's i_am_done) executes
+this command in the developer's workspace — lint + types + complexity, no tests
+(e.g. "make gate") — instead of the lint/typecheck pair, so a red gate is caught
+at the dev's desk. Nullable; projects opt in via the panel.
+
+Revision ID: 029_project_quality_command
+Revises: 028_seed_self_hosted_provider
+Create Date: 2026-06-14
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "029_project_quality_command"
+down_revision = "028_seed_self_hosted_provider"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "projects",
+ sa.Column("quality_command", sa.String(length=500), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("projects", "quality_command")
diff --git a/panel/src/components/projects/create-project-dialog.tsx b/panel/src/components/projects/create-project-dialog.tsx
index 82b0ce9c..4d0a9db8 100644
--- a/panel/src/components/projects/create-project-dialog.tsx
+++ b/panel/src/components/projects/create-project-dialog.tsx
@@ -82,6 +82,7 @@ export function CreateProjectDialog() {
format_command: formData.format_command || undefined,
typecheck_command: formData.typecheck_command || undefined,
build_command: formData.build_command || undefined,
+ quality_command: formData.quality_command || undefined,
});
toast.success("Project created successfully");
setOpen(false);
@@ -271,6 +272,22 @@ export function CreateProjectDialog() {
placeholder="pnpm build"
/>
+
+
+
+
+ setFormData({ ...formData, quality_command: e.target.value })
+ }
+ placeholder="make gate"
+ />
+
+ Fast pre-submit gate (lint + types + complexity, no tests) run
+ in the dev's workspace at hand-off to QA.
+
+
>
)}
diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx
index c8744639..dd6e612a 100644
--- a/panel/src/components/projects/edit-project-dialog.tsx
+++ b/panel/src/components/projects/edit-project-dialog.tsx
@@ -61,6 +61,7 @@ function EditProjectForm({
const [formatCommand, setFormatCommand] = useState(project.format_command || "");
const [typecheckCommand, setTypecheckCommand] = useState(project.typecheck_command || "");
const [buildCommand, setBuildCommand] = useState(project.build_command || "");
+ const [qualityCommand, setQualityCommand] = useState(project.quality_command || "");
// Token handling
const [newToken, setNewToken] = useState("");
@@ -88,6 +89,7 @@ function EditProjectForm({
format_command: formatCommand || undefined,
typecheck_command: typecheckCommand || undefined,
build_command: buildCommand || undefined,
+ quality_command: qualityCommand || undefined,
};
// Handle token update
@@ -296,6 +298,20 @@ function EditProjectForm({
placeholder="pnpm build"
/>
+
+
+
+
setQualityCommand(e.target.value)}
+ placeholder="make gate"
+ />
+
+ Fast pre-submit gate (lint + types + complexity, no tests) run in
+ the dev's workspace at hand-off to QA.
+
+
>
)}
diff --git a/panel/src/lib/api/projects.ts b/panel/src/lib/api/projects.ts
index aaf6dcb3..71d9ee18 100644
--- a/panel/src/lib/api/projects.ts
+++ b/panel/src/lib/api/projects.ts
@@ -83,6 +83,7 @@ export const projectsApi = {
format_command: project.format_command ?? null,
typecheck_command: project.typecheck_command ?? null,
build_command: project.build_command ?? null,
+ quality_command: project.quality_command ?? null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
diff --git a/panel/src/types/index.ts b/panel/src/types/index.ts
index 0e420a7e..65c06861 100644
--- a/panel/src/types/index.ts
+++ b/panel/src/types/index.ts
@@ -1108,6 +1108,7 @@ export interface Project {
format_command: string | null;
typecheck_command: string | null;
build_command: string | null;
+ quality_command: string | null;
// Runtime state
workspace_path: string | null;
last_synced_at: string | null;
@@ -1132,6 +1133,7 @@ export interface ProjectCreate {
format_command?: string;
typecheck_command?: string;
build_command?: string;
+ quality_command?: string;
}
export interface ProjectUpdate {
@@ -1148,6 +1150,7 @@ export interface ProjectUpdate {
format_command?: string;
typecheck_command?: string;
build_command?: string;
+ quality_command?: string;
}
export interface ProjectSummary {
diff --git a/roboco/api/schemas/project.py b/roboco/api/schemas/project.py
index 940ae112..3165d8bf 100644
--- a/roboco/api/schemas/project.py
+++ b/roboco/api/schemas/project.py
@@ -42,6 +42,7 @@ class ProjectResponse(BaseModel):
format_command: str | None = None
typecheck_command: str | None = None
build_command: str | None = None
+ quality_command: str | None = None
# Runtime state
workspace_path: str | None = None
@@ -103,6 +104,7 @@ class ProjectCreateRequest(BaseModel):
format_command: str | None = None
typecheck_command: str | None = None
build_command: str | None = None
+ quality_command: str | None = None
class ProjectUpdateRequest(BaseModel):
@@ -126,6 +128,7 @@ class ProjectUpdateRequest(BaseModel):
format_command: str | None = None
typecheck_command: str | None = None
build_command: str | None = None
+ quality_command: str | None = None
# State
is_active: bool | None = None
@@ -165,6 +168,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
format_command=project.format_command,
typecheck_command=project.typecheck_command,
build_command=project.build_command,
+ quality_command=project.quality_command,
workspace_path=project.workspace_path,
last_synced_at=project.last_synced_at,
head_commit=project.head_commit,
diff --git a/roboco/db/tables.py b/roboco/db/tables.py
index 3a218534..25585a08 100644
--- a/roboco/db/tables.py
+++ b/roboco/db/tables.py
@@ -448,6 +448,10 @@ class ProjectTable(Base):
format_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
typecheck_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
build_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ # Fast pre-submit gate command (lint+types+complexity, no tests). When set,
+ # the agent i_am_done gate runs this in the dev's workspace instead of the
+ # lint/typecheck pair — e.g. "make gate".
+ quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Access Control
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
diff --git a/roboco/models/project.py b/roboco/models/project.py
index 83efb4fc..ce449e05 100644
--- a/roboco/models/project.py
+++ b/roboco/models/project.py
@@ -70,6 +70,14 @@ class Project(TimestampMixin):
build_command: str | None = Field(
default=None, description="Command to build (e.g., 'pnpm build')"
)
+ quality_command: str | None = Field(
+ default=None,
+ description=(
+ "Fast pre-submit gate command run in the dev's workspace at "
+ "i_am_done (lint+types+complexity, no tests; e.g. 'make gate'). "
+ "When set it replaces the lint/typecheck pair in the gate."
+ ),
+ )
# Access Control
assigned_cell: Team = Field(..., description="Which cell owns this project")
@@ -120,6 +128,7 @@ class ProjectCreate(RobocoBase):
format_command: str | None = None
typecheck_command: str | None = None
build_command: str | None = None
+ quality_command: str | None = None
class ProjectUpdate(RobocoBase):
@@ -141,6 +150,7 @@ class ProjectUpdate(RobocoBase):
format_command: str | None = None
typecheck_command: str | None = None
build_command: str | None = None
+ quality_command: str | None = None
assigned_cell: Team | None = None
allowed_agents: list[UUID] | None = None
is_active: bool | None = None
diff --git a/roboco/services/git.py b/roboco/services/git.py
index 4f25b8ea..d9e38707 100644
--- a/roboco/services/git.py
+++ b/roboco/services/git.py
@@ -1983,11 +1983,17 @@ class GitService(BaseService):
@staticmethod
def _fast_gate_commands(project: Any) -> list[tuple[str, str]]:
- """The project's non-mutating fast-gate commands (lint + typecheck).
+ """The project's non-mutating fast-gate commands.
- Format and the test suite are intentionally excluded: format mutates
- files, and the slow test run stays on CI.
+ A configured ``quality_command`` is the project's complete fast gate
+ (lint + types + complexity, no tests; e.g. ``make gate``) and takes
+ precedence — it runs alone. Otherwise fall back to the lint + typecheck
+ pair. Format and the test suite are intentionally excluded: format
+ mutates files, and the slow test run stays on CI.
"""
+ quality = getattr(project, "quality_command", None)
+ if quality:
+ return [("quality", quality)]
candidates = (
("lint", getattr(project, "lint_command", None)),
("typecheck", getattr(project, "typecheck_command", None)),
diff --git a/roboco/services/project.py b/roboco/services/project.py
index 98256e6f..2cd7d4a4 100644
--- a/roboco/services/project.py
+++ b/roboco/services/project.py
@@ -103,6 +103,7 @@ class ProjectService(BaseService):
format_command=data.format_command,
typecheck_command=data.typecheck_command,
build_command=data.build_command,
+ quality_command=data.quality_command,
created_by=created_by,
)
diff --git a/tests/unit/gateway/test_quality_gate.py b/tests/unit/gateway/test_quality_gate.py
index 2aa2e9b9..d3e4d035 100644
--- a/tests/unit/gateway/test_quality_gate.py
+++ b/tests/unit/gateway/test_quality_gate.py
@@ -68,6 +68,7 @@ def test_gate_result_summary_and_excerpt() -> None:
def test_fast_gate_commands_picks_lint_and_typecheck() -> None:
project = SimpleNamespace(
+ quality_command=None, # not set → fall back to lint + typecheck
lint_command="uv run ruff check .",
typecheck_command="uv run mypy roboco/",
format_command="uv run ruff format .", # excluded (mutating)
@@ -80,8 +81,21 @@ def test_fast_gate_commands_picks_lint_and_typecheck() -> None:
]
+def test_quality_command_takes_precedence_and_runs_alone() -> None:
+ """A configured quality_command is the complete fast gate (e.g. it also runs
+ complexity/xenon) — it runs alone, not alongside lint/typecheck."""
+ project = SimpleNamespace(
+ quality_command="make gate",
+ lint_command="uv run ruff check .",
+ typecheck_command="uv run mypy roboco/",
+ )
+ assert GitService._fast_gate_commands(project) == [("quality", "make gate")]
+
+
def test_fast_gate_commands_empty_when_unconfigured() -> None:
- project = SimpleNamespace(lint_command=None, typecheck_command=None)
+ project = SimpleNamespace(
+ quality_command=None, lint_command=None, typecheck_command=None
+ )
assert GitService._fast_gate_commands(project) == []