diff --git a/mcp/leadtech_bmad_mcp/README.md b/mcp/leadtech_bmad_mcp/README.md index 84c8e06..fff3bff 100644 --- a/mcp/leadtech_bmad_mcp/README.md +++ b/mcp/leadtech_bmad_mcp/README.md @@ -60,6 +60,18 @@ Documents de référence phase 1 : - `95_a_capitaliser.md` - `CLAUDE.md` projet +## Gates configurables + +Les gates textuelles de `validate_plan()` et `validate_patch()` sont maintenant configurees dans : + +- `config/gates.yaml` + +Le serveur charge ce fichier au demarrage logique et peut etre pointe vers un autre chemin via : + +- `LEADTECH_GATES_CONFIG` + +Les regles purement structurelles qui ne sont pas basees sur du texte libre, comme `changed_files` avec uniquement `_bmad-output/`, restent codees a part. + ## Integration recommandee dans BMAD 1. Analyst @@ -87,6 +99,11 @@ pip install -e ".[dev]" pytest tests -q ``` +Quickstart complet : + +- `docs/quickstart_dev_local.md` +- `docs/rollout_bmad_advisory.md` + ## Rebuild de l'index local Le MCP cherche d'abord un index JSON local a la racine de `LEADTECH_ROOT` : diff --git a/mcp/leadtech_bmad_mcp/config/gates.yaml b/mcp/leadtech_bmad_mcp/config/gates.yaml new file mode 100644 index 0000000..e680c90 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/config/gates.yaml @@ -0,0 +1,83 @@ +labels: + - "Contracts-First / Zod-Infer / No-DTO" + - "Format erreur API: { error: { code, message, requestId } }" + - "Sessions: expiresAt obligatoire et filtre en query" + - "Navigation reactive useEffect" + - "NestJS guards: AuthGuard en premier" + +validate_plan: + - id: backend_contracts + domains: [backend] + required_any: + - "zod|z\\.object|contracts?[-_]first|shared[-_]contract" + actions: + - target: must_do + message: "Ajouter explicitement la strategie contracts-first / Zod." + - target: blocking_issues + message: "Plan backend sans reference aux contrats partages." + strict_only: true + + - id: backend_request_id + domains: [backend] + if_any: + - "\\berrors?\\b|\\berreurs?\\b|\\bexceptions?\\b" + required_any: + - "request[_-]?id" + actions: + - target: should_do + message: "Ajouter la normalisation d'erreur API avec requestId." + + - id: test_strategy + required_any: + - "test|spec" + actions: + - target: must_do + message: "Ajouter une strategie de test (unit/integration/e2e)." + + - id: parallel_dependencies + if_any: + - "parallel" + required_any: + - "depends[-_]on|can[-_]run[-_]with" + actions: + - target: red_flags + message: "Parallel mentionne sans clarifier Depends-on/Can-run-with." + +validate_patch: + - id: backend_session_expires_at + domains: [backend] + if_any: + - "sessions?" + required_any: + - "expires[_-]?at" + actions: + - target: blocking_issues + message: "Session modifiee sans expiresAt visible dans le diff." + + - id: backend_request_id + domains: [backend] + if_any: + - "\\berrors?\\b|\\berreurs?\\b|\\bexceptions?\\b" + required_any: + - "request[_-]?id" + actions: + - target: must_do + message: "Verifier le format erreur API standard avec requestId." + + - id: backend_auth_guard + domains: [backend] + if_any: + - "guard" + required_any: + - "auth[_\\s-]?guard|jwtauthguard" + actions: + - target: red_flags + message: "Usage de guard sans trace explicite d'ordre AuthGuard en premier." + + - id: tests_visible_in_diff + required_any: + - "test|spec|describe\\s*\\(|it\\s*\\(|expect\\s*\\(" + actions: + - target: should_do + message: "Aucun test visible dans le diff: verifier couverture manuelle." + strict_only: true diff --git a/mcp/leadtech_bmad_mcp/docs/implementation_plan.md b/mcp/leadtech_bmad_mcp/docs/implementation_plan.md index d8b46ea..ed4aa75 100644 --- a/mcp/leadtech_bmad_mcp/docs/implementation_plan.md +++ b/mcp/leadtech_bmad_mcp/docs/implementation_plan.md @@ -15,9 +15,9 @@ Mode d'usage : | Lot | Objectif | Statut | | --- | --- | --- | -| Lot 1 | Contrat MCP v1 + metadonnees `knowledge` + compatibilite loader | En cours avance | +| Lot 1 | Contrat MCP v1 + metadonnees `knowledge` + compatibilite loader | Termine | | Lot 2 | Index compile local + branchement de la recherche MCP dessus | Termine | -| Lot 3 | Gates configurables + packaging + rollout BMAD | A faire | +| Lot 3 | Gates configurables + packaging + rollout BMAD | Termine | --- @@ -56,7 +56,7 @@ Stabiliser le contrat du MCP et preparer un corpus `knowledge/` assez structure - [x] Verifier si `knowledge/backend/patterns/prisma.md` doit aussi entrer dans le noyau pilote - [x] Verifier si `knowledge/frontend/risques/tests.md` doit aussi entrer dans le noyau pilote -- [ ] Faire un commit de cloture explicite du Lot 1 +- [x] Faire un commit de cloture explicite du Lot 1 ### Critere de fin @@ -105,19 +105,19 @@ Sortir les regles du code dur, rendre l'installation reproductible, puis cabler ### Taches -- [ ] Extraire les gates dans une config versionnee -- [ ] Distinguer advisory et blocking dans la config -- [ ] Ajouter des tests sur les faux positifs/faux negatifs des gates -- [ ] Stabiliser l'installation `pip install -e ".[dev]"` -- [ ] Ajouter une doc machine vierge / quickstart -- [ ] Preparer un rollout BMAD advisory -- [ ] Definir 2-3 blocages stricts seulement apres validation +- [x] Extraire les gates dans une config versionnee +- [x] Distinguer advisory et blocking dans la config +- [x] Ajouter des tests sur les faux positifs/faux negatifs des gates +- [x] Stabiliser l'installation `pip install -e ".[dev]"` +- [x] Ajouter une doc machine vierge / quickstart +- [x] Preparer un rollout BMAD advisory +- [x] Definir 2-3 blocages stricts seulement apres validation ### Livrables attendus - `config/gates.yaml` -- quickstart dev local -- procedure de rollout BMAD +- `docs/quickstart_dev_local.md` +- `docs/rollout_bmad_advisory.md` ### Critere de fin @@ -142,6 +142,16 @@ Sortir les regles du code dur, rendre l'installation reproductible, puis cabler - `search_knowledge()` et `search_global_docs()` relis d'abord sur l'index avec fallback scan - script `leadtech-bmad-build-index` ajoute - tests d'integration indexes ajoutes +- commit explicite de cloture Lot 1 + Lot 2: `bafc872` +- Lot 3 demarre +- gates textuelles extraites dans `config/gates.yaml` +- distinction advisory / blocking portee par la config +- chargeur `gates.py` ajoute avec override `LEADTECH_GATES_CONFIG` +- couverture de tests gates et serveur etendue +- install `pip install -e ".[dev]"` reverifiee sur venv propre +- quickstart local ajoute +- procedure de rollout BMAD advisory ajoutee +- shortlist de 3 blocages stricts recommandes documentee --- diff --git a/mcp/leadtech_bmad_mcp/docs/quickstart_dev_local.md b/mcp/leadtech_bmad_mcp/docs/quickstart_dev_local.md new file mode 100644 index 0000000..8bc8288 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/docs/quickstart_dev_local.md @@ -0,0 +1,53 @@ +# leadtech-bmad-mcp — Quickstart dev local + +Objectif : installer et verifier rapidement le serveur MCP sur une machine vierge ou un nouvel environnement local. + +## Prerequis + +- Python `>= 3.10` +- acces au repo Lead_tech +- shell POSIX + +## Installation + +```bash +cd /srv/helpers/_Assistant_Lead_Tech/mcp/leadtech_bmad_mcp +python3.10 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +## Verification minimale + +```bash +source .venv/bin/activate +pytest tests -q +leadtech-bmad-build-index +``` + +Attendus : + +- tous les tests passent +- un fichier `.leadtech_mcp_index.json` est genere a la racine de `LEADTECH_ROOT` + +## Lancement local + +```bash +source .venv/bin/activate +export LEADTECH_ROOT=/srv/helpers/_Assistant_Lead_Tech +leadtech-bmad-mcp +``` + +En dev local hors `/srv/helpers`, `LEADTECH_ROOT` peut pointer vers le checkout courant. + +## Variables utiles + +- `LEADTECH_ROOT` : racine de la base Lead_tech +- `LEADTECH_GATES_CONFIG` : chemin alternatif vers `config/gates.yaml` +- `LEADTECH_MCP_ALLOW_WRITE=1` : active les writes controles (`95_a_capitaliser.md`, memoire projet) + +## Depannage + +- `ModuleNotFoundError: yaml` : relancer `pip install -e ".[dev]"` pour installer `PyYAML` +- `command not found: pytest` : verifier que `.venv` est active +- index ecrit au mauvais endroit : verifier `LEADTECH_ROOT` diff --git a/mcp/leadtech_bmad_mcp/docs/rollout_bmad_advisory.md b/mcp/leadtech_bmad_mcp/docs/rollout_bmad_advisory.md new file mode 100644 index 0000000..9830fea --- /dev/null +++ b/mcp/leadtech_bmad_mcp/docs/rollout_bmad_advisory.md @@ -0,0 +1,155 @@ +# leadtech-bmad-mcp — Rollout BMAD advisory + +Ce document decrit comment brancher `leadtech-bmad-mcp` dans BMAD sans introduire de blocage automatique au demarrage. + +## Objectif + +- injecter la guidance Lead_tech au bon moment +- rendre les gates visibles dans les artefacts BMAD +- observer les faux positifs avant d'activer des blocages stricts + +## Principe + +Phase de depart : + +- **advisory only** +- aucune story ne s'arrete automatiquement sur la seule base du MCP +- la decision finale reste humaine + +Le MCP doit d'abord servir a : + +- augmenter le niveau de vigilance +- homogeniser la lecture des patterns et risques +- detecter les cas manifestement fragiles + +## Points d'injection BMAD + +### 1. Analyst — entree de story + +Appel recommande : + +```txt +get_guidance(domain, task_type="analysis", story_text=...) +``` + +Sortie a reporter dans la story : + +- patterns a appliquer +- risques a eviter +- docs prioritaires a lire + +### 2. Builder — avant implementation + +Appel recommande : + +```txt +validate_plan(domain, plan_text, agent_role="builder", strict=false) +``` + +Regle advisory : + +- les `blocking_issues` sont traces mais ne bloquent pas encore automatiquement +- le Builder doit repondre explicitement a chaque point dans son plan ou son Dev Agent Record + +### 3. Builder — apres diff + +Appel recommande : + +```txt +validate_patch(domain, diff_text, changed_files, strict=false) +``` + +Regle advisory : + +- ajouter un resume dans le Dev Agent Record +- si `blocking_issues` apparait, le statut cible doit devenir `review`, pas `done` + +### 4. Reviewer — revue finale + +Appels recommandes : + +```txt +emit_checklist(agent_role="reviewer", domain, story_text) +validate_patch(domain, diff_text, changed_files, strict=false) +``` + +Regle advisory : + +- un `blocking_issues` n'est pas un veto automatique +- mais il impose une justification explicite si la PR/story est acceptee + +### 5. Curator — apprentissage + +Appels recommandes : + +```txt +propose_capitalization(..., dry_run=true) +triage_capitalization() +``` + +## Trace minimale a imposer + +Chaque story doit contenir une section courte `Leadtech MCP Gates` avec : + +- timestamp +- tools appeles +- resume de `must_do`, `red_flags`, `blocking_issues` +- decision humaine prise + +Format minimal : + +```txt +Leadtech MCP Gates +- 2026-03-31 16:20 +- get_guidance / validate_plan / validate_patch +- blocking_issues: 0 +- red_flags: AuthGuard non visible dans le diff +- decision: review demandee avant done +``` + +## Observabilite a suivre pendant la phase advisory + +Sur 10 a 20 stories, relever : + +- nombre de `blocking_issues` +- nombre de faux positifs +- nombre de cas ou le MCP a evite une regression reelle +- domaines les plus stables (`backend`, `workflow`, etc.) + +Objectif de sortie de phase advisory : + +- faux positifs faibles sur les gates promues +- vocabulaire compris par les agents BMAD +- section `Leadtech MCP Gates` remplie de facon reguliere + +## Blocages stricts recommandes apres validation + +Ne promouvoir en strict que des regles avec signal fort et faible ambiguite. + +### Blocage strict 1 + +- `validate_patch` : `Patch sans fichier source: seulement des artefacts BMAD.` +- Pourquoi : signal tres fort, directement aligne avec `story-tracking.md` + +### Blocage strict 2 + +- `validate_patch` : `Session modifiee sans expiresAt visible dans le diff.` +- Pourquoi : gate backend precise, issue recurrente et couteuse + +### Blocage strict 3 + +- `validate_plan` : `Plan backend sans reference aux contrats partages.` +- Pourquoi : aligne avec la regle contracts-first, utile surtout sur monorepos TS fullstack + +## Ce qu'il ne faut pas promouvoir trop tot + +- warning `requestId` sur texte libre si la formulation du plan/diff est trop partielle +- warning `AuthGuard` sur des extraits de diff incomplets +- suggestions de tests si la story est purement documentaire + +## Sequence recommandee + +1. phase advisory sur toutes les stories backend + workflow +2. promotion du blocage strict `bmad-only artifacts` +3. promotion du blocage strict `sessions sans expiresAt` +4. promotion eventuelle du blocage `contracts-first` si le contexte repo le justifie diff --git a/mcp/leadtech_bmad_mcp/pyproject.toml b/mcp/leadtech_bmad_mcp/pyproject.toml index cc1ec24..db7ad3a 100644 --- a/mcp/leadtech_bmad_mcp/pyproject.toml +++ b/mcp/leadtech_bmad_mcp/pyproject.toml @@ -9,7 +9,8 @@ description = "Serveur MCP sidecar pour Lead_tech et workflow BMAD" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "mcp>=1.2.0" + "mcp>=1.2.0", + "PyYAML>=6.0" ] [project.optional-dependencies] diff --git a/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/gates.py b/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/gates.py new file mode 100644 index 0000000..3b50376 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/gates.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import os +import re +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + + +def get_gates_config_path() -> Path: + override = os.getenv("LEADTECH_GATES_CONFIG") + if override: + return Path(override).resolve() + return Path(__file__).resolve().parents[2] / "config" / "gates.yaml" + + +@lru_cache(maxsize=4) +def load_gates_config(config_path: str | None = None) -> dict[str, Any]: + path = Path(config_path).resolve() if config_path else get_gates_config_path() + payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return payload if isinstance(payload, dict) else {} + + +def get_gate_labels() -> list[str]: + config = load_gates_config() + labels = config.get("labels", []) + return [str(label) for label in labels] + + +def _matches_any(patterns: list[str], text: str) -> bool: + return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns) + + +def apply_text_rules(tool_name: str, domain: str, text: str, strict: bool) -> list[dict[str, str]]: + config = load_gates_config() + rules = config.get(tool_name, []) + findings: list[dict[str, str]] = [] + + for rule in rules: + if not isinstance(rule, dict): + continue + + domains = rule.get("domains", []) + if domains and domain not in domains: + continue + + if_any = [str(pattern) for pattern in rule.get("if_any", [])] + if if_any and not _matches_any(if_any, text): + continue + + unless_any = [str(pattern) for pattern in rule.get("unless_any", [])] + if unless_any and _matches_any(unless_any, text): + continue + + required_any = [str(pattern) for pattern in rule.get("required_any", [])] + if required_any and _matches_any(required_any, text): + continue + + for action in rule.get("actions", []): + if not isinstance(action, dict): + continue + if action.get("strict_only") and not strict: + continue + findings.append( + { + "target": str(action["target"]), + "message": str(action["message"]), + "rule_id": str(rule.get("id", "")), + } + ) + + return findings diff --git a/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py b/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py index b7fa876..11bdf7d 100644 --- a/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py +++ b/mcp/leadtech_bmad_mcp/src/leadtech_bmad_mcp/server.py @@ -9,6 +9,7 @@ from typing import Literal from mcp.server.fastmcp import FastMCP +from .gates import apply_text_rules, get_gate_labels from .knowledge import get_paths, read_knowledge_doc, read_text, search_knowledge, search_global_docs from .schemas import CONFIDENCE_HIGH, CONFIDENCE_LOW, CONFIDENCE_MEDIUM, add_reference, empty_gate_output from .triage import parse_capitalisation_entries, novelty_level, scope_level @@ -18,30 +19,9 @@ TaskType = Literal["analysis", "implementation", "review", "debug", "architectur AgentRole = Literal["analyst", "builder", "reviewer", "curator"] mcp = FastMCP(name="leadtech-bmad-mcp") - -GATES = [ - "Contracts-First / Zod-Infer / No-DTO", - "Format erreur API: { error: { code, message, requestId } }", - "Sessions: expiresAt obligatoire et filtre en query", - "Navigation reactive useEffect", - "NestJS guards: AuthGuard en premier", -] - -# Patterns compilés une fois — couvrent camelCase, snake_case, kebab-case et variantes courantes -_RE_CONTRACT = re.compile(r"zod|z\.object|contracts?[-_]first|shared[-_]contract", re.IGNORECASE) -_RE_REQUEST_ID = re.compile(r"request[_-]?id", re.IGNORECASE) -_RE_EXPIRES_AT = re.compile(r"expires[_-]?at", re.IGNORECASE) -_RE_AUTH_GUARD = re.compile(r"auth[_\s-]?guard|jwtauthguard", re.IGNORECASE) -_RE_PARALLEL = re.compile(r"parallel", re.IGNORECASE) -_RE_DEPENDS_ON = re.compile(r"depends[-_]on|can[-_]run[-_]with", re.IGNORECASE) -_RE_SESSION = re.compile(r"sessions?", re.IGNORECASE) -_RE_ERROR = re.compile(r"\berrors?\b|\berreurs?\b|\bexceptions?\b", re.IGNORECASE) -_RE_GUARD = re.compile(r"guard", re.IGNORECASE) - - def _base_output() -> dict: payload = empty_gate_output() - payload["gates"] = GATES + payload["gates"] = get_gate_labels() return payload @@ -152,19 +132,8 @@ def get_guidance(domain: Domain, task_type: TaskType, story_text: str = "", keyw def validate_plan(domain: Domain, plan_text: str, agent_role: AgentRole = "builder", strict: bool = True) -> dict: out = _base_output() - if domain == "backend" and not _RE_CONTRACT.search(plan_text): - out["must_do"].append("Ajouter explicitement la strategie contracts-first / Zod.") - if strict: - out["blocking_issues"].append("Plan backend sans reference aux contrats partages.") - - if domain == "backend" and not _RE_REQUEST_ID.search(plan_text) and _RE_ERROR.search(plan_text): - out["should_do"].append("Ajouter la normalisation d'erreur API avec requestId.") - - if not re.search(r"test|spec", plan_text, re.IGNORECASE): - out["must_do"].append("Ajouter une strategie de test (unit/integration/e2e).") - - if _RE_PARALLEL.search(plan_text) and not _RE_DEPENDS_ON.search(plan_text): - out["red_flags"].append("Parallel mentionne sans clarifier Depends-on/Can-run-with.") + for finding in apply_text_rules("validate_plan", domain, plan_text, strict): + out[finding["target"]].append(finding["message"]) add_reference(out, str(get_paths().root / "80_bmad/process_llm_et_parallelisation.md"), "Regles de synchronisation BMAD") out["confidence"] = CONFIDENCE_HIGH if not out["blocking_issues"] else CONFIDENCE_MEDIUM @@ -175,13 +144,8 @@ def validate_plan(domain: Domain, plan_text: str, agent_role: AgentRole = "build def validate_patch(domain: Domain, diff_text: str, changed_files: list[str] | None = None, strict: bool = True) -> dict: out = _base_output() - if domain == "backend": - if not _RE_EXPIRES_AT.search(diff_text) and _RE_SESSION.search(diff_text): - out["blocking_issues"].append("Session modifiee sans expiresAt visible dans le diff.") - if not _RE_REQUEST_ID.search(diff_text) and _RE_ERROR.search(diff_text): - out["must_do"].append("Verifier le format erreur API standard avec requestId.") - if _RE_GUARD.search(diff_text) and not _RE_AUTH_GUARD.search(diff_text): - out["red_flags"].append("Usage de guard sans trace explicite d'ordre AuthGuard en premier.") + for finding in apply_text_rules("validate_patch", domain, diff_text, strict): + out[finding["target"]].append(finding["message"]) if changed_files and all("_bmad-output/" in f for f in changed_files): out["blocking_issues"].append("Patch sans fichier source: seulement des artefacts BMAD.") diff --git a/mcp/leadtech_bmad_mcp/tests/test_gates.py b/mcp/leadtech_bmad_mcp/tests/test_gates.py new file mode 100644 index 0000000..d490f00 --- /dev/null +++ b/mcp/leadtech_bmad_mcp/tests/test_gates.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from leadtech_bmad_mcp.gates import apply_text_rules, get_gate_labels, load_gates_config + + +def test_load_gates_config_reads_yaml(): + config = load_gates_config() + assert config["labels"] + assert config["validate_plan"] + assert config["validate_patch"] + + +def test_get_gate_labels_returns_configured_labels(): + labels = get_gate_labels() + assert "Contracts-First / Zod-Infer / No-DTO" in labels + + +def test_apply_text_rules_triggers_backend_contracts_blocking_in_strict_mode(): + findings = apply_text_rules("validate_plan", "backend", "Plan backend sans mention de schema. Tests inclus.", strict=True) + assert any(item["target"] == "blocking_issues" for item in findings) + + +def test_apply_text_rules_skips_backend_contracts_blocking_in_non_strict_mode(): + findings = apply_text_rules("validate_plan", "backend", "Plan backend sans mention de schema. Tests inclus.", strict=False) + assert not any(item["target"] == "blocking_issues" for item in findings) + assert any(item["target"] == "must_do" for item in findings) + + +def test_apply_text_rules_respects_required_pattern(): + findings = apply_text_rules("validate_plan", "backend", "Approche contracts-first avec Zod et tests.", strict=True) + assert not any(item["rule_id"] == "backend_contracts" for item in findings) + + +def test_apply_text_rules_skips_test_hint_when_tests_present(): + findings = apply_text_rules("validate_patch", "backend", "describe('x', () => { it('works', () => {}) })", strict=True) + assert not any(item["rule_id"] == "tests_visible_in_diff" for item in findings)