Feature/architectural conventions standard (#243)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(content): drop dead TaskDescription.with_baseline_constraints

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

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-22 12:37:46 +02:00
committed by GitHub
co-authored by Renn F
parent 01e10ad693
commit 16789c1ca7
76 changed files with 4910 additions and 74 deletions
@@ -0,0 +1,227 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
conventionsApi,
type ConventionsActionResult,
type ConventionsStandard,
type RuleLevel,
} from "@/lib/api/conventions";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
function actionToast(verb: string, result: ConventionsActionResult): void {
if (result.created && result.pr_number != null) {
toast.success(`${verb}: opened PR #${result.pr_number} on ${result.branch}`);
} else {
toast.success(
`${verb}: prepared on ${result.branch} (no remote PR — workspace not cloned)`,
);
}
}
export function ConventionsTab({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<ConventionsStandard | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["conventions", projectId],
queryFn: () => conventionsApi.get(projectId),
});
const { data: findings } = useQuery({
queryKey: ["conventions-findings", projectId],
queryFn: () => conventionsApi.findings(projectId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["conventions", projectId] });
const save = useMutation({
mutationFn: (standard: ConventionsStandard) =>
conventionsApi.update(projectId, standard),
onSuccess: (result) => {
actionToast("Saved", result);
setDraft(null);
invalidate();
},
onError: (error) =>
toast.error(
`Save failed: ${error instanceof Error ? error.message : "unknown error"}`,
),
});
const restore = useMutation({
mutationFn: () => conventionsApi.restore(projectId),
onSuccess: (result) => {
actionToast("Restore", result);
invalidate();
},
onError: (error) =>
toast.error(
`Restore failed: ${error instanceof Error ? error.message : "unknown error"}`,
),
});
if (isLoading) {
return (
<p className="py-4 text-sm text-muted-foreground">Loading conventions</p>
);
}
const standard = draft ?? data?.standard ?? null;
if (!standard || !data) {
return (
<p className="py-4 text-sm text-muted-foreground">
No conventions available for this project.
</p>
);
}
const setRuleLevel = (name: string, level: RuleLevel) =>
setDraft({
...standard,
rules: { ...standard.rules, [name]: { name, level } },
});
const degraded = data.health.status !== "ok";
return (
<div className="space-y-4 py-2">
{degraded && (
<Card className="border-amber-500/40">
<CardHeader>
<CardTitle className="text-sm">
Conventions degraded {data.health.status}
</CardTitle>
<CardDescription>
The committed file is missing or unparseable; the effective map
fell back to the last-good cache plus auto-derived defaults.
</CardDescription>
</CardHeader>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-sm">Module boundaries</CardTitle>
<CardDescription>
Which definition kinds are forbidden in each module.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{standard.modules.length === 0 && (
<p className="text-sm text-muted-foreground">No modules mapped yet.</p>
)}
{standard.modules.map((module) => (
<div
key={module.path}
className="flex items-start justify-between gap-4 text-sm"
>
<div className="min-w-0">
<code>{module.path}</code>{" "}
<span className="text-muted-foreground"> {module.purpose}</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{module.forbidden.map((kind) => (
<Badge key={kind} variant="secondary">
no {kind}
</Badge>
))}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Rules</CardTitle>
<CardDescription>
Toggle a rule between warn (advisory) and block (refuses the gate).
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{Object.values(standard.rules).map((rule) => (
<div
key={rule.name}
className="flex items-center justify-between gap-4"
>
<span className="text-sm">{rule.name.replace(/_/g, " ")}</span>
<div className="flex items-center gap-2">
<span className="w-10 text-right text-xs text-muted-foreground">
{rule.level}
</span>
<Switch
checked={rule.level === "block"}
onCheckedChange={(checked) =>
setRuleLevel(rule.name, checked ? "block" : "warn")
}
/>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Recent violations</CardTitle>
<CardDescription>
The latest findings recorded across this project&apos;s tasks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{(!findings || findings.length === 0) && (
<p className="text-sm text-muted-foreground">
No violations recorded yet.
</p>
)}
{(findings ?? []).map((finding, index) => (
<div
key={`${finding.file}:${finding.line}:${finding.rule}:${index}`}
className="flex items-start justify-between gap-4 text-sm"
>
<div className="min-w-0">
<code>
{finding.file}:{finding.line}
</code>{" "}
<span className="text-muted-foreground">{finding.message}</span>
</div>
<Badge variant={finding.level === "block" ? "destructive" : "secondary"}>
{finding.rule}
</Badge>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() => restore.mutate()}
>
Restore from last-good
</Button>
<Button
size="sm"
disabled={draft == null || save.isPending}
onClick={() => draft && save.mutate(draft)}
>
Save to repo
</Button>
</div>
</div>
);
}
@@ -22,6 +22,13 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
@@ -342,13 +349,24 @@ export function EditProjectDialog({ projectId, open, onOpenChange }: EditProject
<Skeleton className="h-10 w-full" />
</div>
) : project ? (
// Key forces remount when project changes, resetting form state
<EditProjectForm
key={project.id}
project={project}
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
/>
<Tabs defaultValue="settings">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="conventions">Conventions</TabsTrigger>
</TabsList>
<TabsContent value="settings">
{/* Key forces remount when project changes, resetting form state */}
<EditProjectForm
key={project.id}
project={project}
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
/>
</TabsContent>
<TabsContent value="conventions">
<ConventionsTab projectId={projectId} />
</TabsContent>
</Tabs>
) : (
<div className="py-8 text-center text-muted-foreground">Project not found</div>
)}
@@ -28,6 +28,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
provisioning_enabled: "Auto-provision projects from approved pitches.",
toolchain_match_enabled:
"Provision each agent workspace with the target project's Python (not RoboCo's) and block delivery gates when its test suite can't be executed.",
conventions_enabled:
"Enforce a per-project architectural standard (.roboco/conventions.yml): inject the map, attach baseline constraints, and block i_am_done / pr_pass on misplaced definitions or lint suppressions.",
rag_auto_update_enabled: "Keep the knowledge base index refreshed automatically.",
transcript_prune_enabled: "Run the background sweep that prunes old transcripts.",
};
+110
View File
@@ -0,0 +1,110 @@
import api from "./client";
// Mirrors roboco.foundation.policy.conventions.models — kept in sync by the
// TS<->Python parity test (tests/unit/foundation/policy/conventions/test_ts_parity.py).
export type RuleLevel = "warn" | "block";
export type DefinitionKind =
| "model"
| "route"
| "helper"
| "business_logic"
| "component"
| "other";
export interface ConventionsModule {
path: string;
purpose: string;
forbidden: DefinitionKind[];
}
export interface ConventionsRule {
name: string;
level: RuleLevel;
}
export interface ConventionsCustomRule {
id: string;
pattern: string;
message: string;
level: RuleLevel;
languages: string[];
}
export interface ConventionsWaiver {
path: string;
rule: string;
reason: string;
}
export interface ConventionsStandard {
version: number;
languages: string[];
modules: ConventionsModule[];
rules: Record<string, ConventionsRule>;
custom: ConventionsCustomRule[];
waivers: ConventionsWaiver[];
}
export interface ConventionsHealth {
status: string;
head_sha: string;
last_ok_sha: string | null;
}
export interface ConventionsResponse {
standard: ConventionsStandard;
health: ConventionsHealth;
}
export interface ConventionsActionResult {
pr_number: number | null;
branch: string;
created: boolean;
}
export interface ConventionFinding {
file: string;
line: number;
rule: string;
level: RuleLevel;
kind: string | null;
message: string;
task_id: string | null;
detected_at: string;
}
export const conventionsApi = {
// GET the project's effective map + health.
get: async (projectId: string): Promise<ConventionsResponse> => {
const { data } = await api.get<ConventionsResponse>(
`/projects/${projectId}/conventions`,
);
return data;
},
// PUT an edited standard — opens a PR committing it back (PM+).
update: async (
projectId: string,
standard: ConventionsStandard,
): Promise<ConventionsActionResult> => {
const { data } = await api.put<ConventionsActionResult>(
`/projects/${projectId}/conventions`,
standard,
);
return data;
},
// POST restore — re-commits the file from the last-good map (PM+).
restore: async (projectId: string): Promise<ConventionsActionResult> => {
const { data } = await api.post<ConventionsActionResult>(
`/projects/${projectId}/conventions/restore`,
{},
);
return data;
},
// GET the recent violations feed for the project.
findings: async (projectId: string): Promise<ConventionFinding[]> => {
const { data } = await api.get<ConventionFinding[]>(
`/projects/${projectId}/conventions/findings`,
);
return data;
},
};