leadtech-bmad-mcp: close lot 3 rollout and gates config

This commit is contained in:
MaksTinyWorkshop
2026-03-31 16:12:42 +02:00
parent bafc872030
commit 6adc35d0ac
9 changed files with 448 additions and 55 deletions

View File

@@ -60,6 +60,18 @@ Documents de référence phase 1 :
- `95_a_capitaliser.md` - `95_a_capitaliser.md`
- `CLAUDE.md` projet - `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 ## Integration recommandee dans BMAD
1. Analyst 1. Analyst
@@ -87,6 +99,11 @@ pip install -e ".[dev]"
pytest tests -q pytest tests -q
``` ```
Quickstart complet :
- `docs/quickstart_dev_local.md`
- `docs/rollout_bmad_advisory.md`
## Rebuild de l'index local ## Rebuild de l'index local
Le MCP cherche d'abord un index JSON local a la racine de `LEADTECH_ROOT` : Le MCP cherche d'abord un index JSON local a la racine de `LEADTECH_ROOT` :

View File

@@ -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

View File

@@ -15,9 +15,9 @@ Mode d'usage :
| Lot | Objectif | Statut | | 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 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/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 - [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 ### Critere de fin
@@ -105,19 +105,19 @@ Sortir les regles du code dur, rendre l'installation reproductible, puis cabler
### Taches ### Taches
- [ ] Extraire les gates dans une config versionnee - [x] Extraire les gates dans une config versionnee
- [ ] Distinguer advisory et blocking dans la config - [x] Distinguer advisory et blocking dans la config
- [ ] Ajouter des tests sur les faux positifs/faux negatifs des gates - [x] Ajouter des tests sur les faux positifs/faux negatifs des gates
- [ ] Stabiliser l'installation `pip install -e ".[dev]"` - [x] Stabiliser l'installation `pip install -e ".[dev]"`
- [ ] Ajouter une doc machine vierge / quickstart - [x] Ajouter une doc machine vierge / quickstart
- [ ] Preparer un rollout BMAD advisory - [x] Preparer un rollout BMAD advisory
- [ ] Definir 2-3 blocages stricts seulement apres validation - [x] Definir 2-3 blocages stricts seulement apres validation
### Livrables attendus ### Livrables attendus
- `config/gates.yaml` - `config/gates.yaml`
- quickstart dev local - `docs/quickstart_dev_local.md`
- procedure de rollout BMAD - `docs/rollout_bmad_advisory.md`
### Critere de fin ### 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 - `search_knowledge()` et `search_global_docs()` relis d'abord sur l'index avec fallback scan
- script `leadtech-bmad-build-index` ajoute - script `leadtech-bmad-build-index` ajoute
- tests d'integration indexes ajoutes - 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
--- ---

View File

@@ -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`

View File

@@ -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

View File

@@ -9,7 +9,8 @@ description = "Serveur MCP sidecar pour Lead_tech et workflow BMAD"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"mcp>=1.2.0" "mcp>=1.2.0",
"PyYAML>=6.0"
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -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

View File

@@ -9,6 +9,7 @@ from typing import Literal
from mcp.server.fastmcp import FastMCP 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 .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 .schemas import CONFIDENCE_HIGH, CONFIDENCE_LOW, CONFIDENCE_MEDIUM, add_reference, empty_gate_output
from .triage import parse_capitalisation_entries, novelty_level, scope_level 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"] AgentRole = Literal["analyst", "builder", "reviewer", "curator"]
mcp = FastMCP(name="leadtech-bmad-mcp") 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: def _base_output() -> dict:
payload = empty_gate_output() payload = empty_gate_output()
payload["gates"] = GATES payload["gates"] = get_gate_labels()
return payload 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: def validate_plan(domain: Domain, plan_text: str, agent_role: AgentRole = "builder", strict: bool = True) -> dict:
out = _base_output() out = _base_output()
if domain == "backend" and not _RE_CONTRACT.search(plan_text): for finding in apply_text_rules("validate_plan", domain, plan_text, strict):
out["must_do"].append("Ajouter explicitement la strategie contracts-first / Zod.") out[finding["target"]].append(finding["message"])
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.")
add_reference(out, str(get_paths().root / "80_bmad/process_llm_et_parallelisation.md"), "Regles de synchronisation BMAD") 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 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: def validate_patch(domain: Domain, diff_text: str, changed_files: list[str] | None = None, strict: bool = True) -> dict:
out = _base_output() out = _base_output()
if domain == "backend": for finding in apply_text_rules("validate_patch", domain, diff_text, strict):
if not _RE_EXPIRES_AT.search(diff_text) and _RE_SESSION.search(diff_text): out[finding["target"]].append(finding["message"])
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.")
if changed_files and all("_bmad-output/" in f for f in changed_files): 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.") out["blocking_issues"].append("Patch sans fichier source: seulement des artefacts BMAD.")

View File

@@ -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)