{
    "model": "gpt-5.6-terra",
    "instructions": "Tu es un moteur de développement logiciel. Analyse le projet local, demande les autorisations nécessaires avant de lire ou modifier les fichiers, puis propose des changements précis, réversibles et vérifiables.\n\n---\n\n=== BEGIN CONTEXT PAYLOAD INSPECTOR ===\nPayload final: 148220 octets | sha256=723ce2bbcd46ff85ed6287a871a27408007b3ac4810ceef32c626f3c55d1995c\n[OK] app/Services/DevelopmentCodeExplorerService.php | 83944 octets\n[OK] app/Views/admin/development/index.php | 37062 octets\n=== END CONTEXT PAYLOAD INSPECTOR ===\n\n\n\n=== CONTEXTE FINAL VERROUILLE evolution — SOURCE DE VERITE ===\nv2.4.7 : tout chemin exact explicite et resolu est promu OBLIGATOIRE, meme s'il avait deja ete decouvert. Les blocs PINNED sont injectes entiers et jamais tronques.\nCes fichiers viennent d'etre relus sur le disque juste avant la generation du diff. Les marqueurs COMPLET signifient que le contenu entre BEGIN/END est integral.\n\nPLAN D'EXPLORATION EXECUTE PAR evolution :\n- path: app/Views/admin/development/index.php => 1 nouveau(x) fichier(s), cible exacte promue OBLIGATOIRE\n- route: auth/forgot-password =[EXPURGÉ] 3 nouveau(x) fichier(s) épinglé(s)\n- route: auth/reset-password/(:segment) => 0 nouveau(x) fichier(s) épinglé(s)\n- PIN path obligatoire -> app/Views/admin/development/index.php\n- PIN route auth/forgot-password -> app/Config/Routes.php\n- PIN controller route auth/forgot-password -> app/Controllers/Auth.php\n- PIN view route auth/forgot-password -> app/Views/auth/form.php\n- PIN route auth/reset-password/(:segment) -> app/Config/Routes.php\n- PIN controller route auth/reset-password/(:segment) -> app/Controllers/Auth.php\n- PIN view route auth/reset-password/(:segment) -> app/Views/auth/form.php\n\n=== CONTEXTE OBLIGATOIRE — CHEMINS EXACTS AUTORISES ===\n\n--- BEGIN PINNED app/Views/admin/development/index.php | 37062 octets | sha256=103c68e56b8983fe54ad133a50cd3df16acbe8445a6e60e7e4d2919df21c3a5a | COMPLET ---\n<?= $this->extend('layouts/admin') ?>\n<?= $this->section('sidebar') ?>\n<div class=\"dev-sidebar-heading\">\n    <div class=\"d-flex align-items-center justify-content-between gap-2\">\n        <a class=\"dev-sidebar-brand\" href=\"<?= site_url('/') ?>\"><img src=\"<?= base_url('assets/img/nexum-logo-150.png') ?>\" alt=\"\" class=\"me-2\" style=\"width:1.25rem;height:1.25rem;object-fit:contain\"><span>Nexum IA</span></a>\n        <button class=\"btn btn-sm btn-outline-secondary dev-sidebar-toggle\" type=\"button\" id=\"devSidebarToggle\" aria-label=\"Replier le panneau\" title=\"Replier le panneau\"><i class=\"bi bi-chevron-left\"></i></button>\n    </div>\n    <div class=\"small text-secondary mt-3 mb-2\">Conversations techniques</div>\n</div>\n<div class=\"list-group list-group-flush dev-history\">\n    <div class=\"d-flex justify-content-center mb-2\"><a href=\"<?= site_url('admin/evolution-ia') ?>\" class=\"btn btn-sm btn-dark\"><i class=\"bi bi-plus-lg\"></i><span class=\"dev-sidebar-label ms-1\">Nouvelle conversation</span></a></div>\n    <div class=\"dev-history-list\">\n    <?php foreach($recent as $c): ?>\n    <div class=\"d-flex align-items-center dev-history-item\">\n        <a class=\"list-group-item list-group-item-action border-start-0 border-end-0 rounded-0 flex-grow-1 min-w-0 <?= (int)($active['id']??0)===(int)$c['id']?'active':'' ?>\" href=\"<?= site_url('admin/evolution-ia?conversation_id='.(int)$c['id']) ?>\" title=\"<?= esc($c['title']) ?>\"><div class=\"text-truncate fw-semibold\"><?= esc($c['title']) ?></div></a>\n        <form method=\"post\" action=\"<?= site_url('admin/evolution-ia/'.(int)$c['id'].'/supprimer') ?>\" class=\"flex-shrink-0\" onsubmit=\"return confirm('Supprimer cette conversation technique ?')\"><?= csrf_field() ?><button class=\"btn btn-sm btn-outline-danger\" type=\"submit\" title=\"Supprimer la conversation\" aria-label=\"Supprimer la conversation <?= esc($c['title']) ?>\"><i class=\"bi bi-trash\"></i><span class=\"visually-hidden\">Supprimer</span></button></form>\n    </div>\n    <?php endforeach; ?>\n    </div><?php if(!$recent): ?><p class=\"small text-secondary mb-0 dev-sidebar-label\">Aucune conversation.</p><?php endif; ?>\n</div>\n<?= $this->endSection() ?>\n<?= $this->section('content') ?>\n<div class=\"container-fluid py-4 dev-engine\">\n    <div class=\"d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4\">\n        <div>\n            <h1 class=\"h3 mb-1\"><i class=\"bi bi-code me-2\"></i>Nexum <span class=\"nexum-gradient\">AI</span> · Développement</h1>\n            <p class=\"text-secondary mb-0\">\n                Plateforme de développement assistée par IA\n            </p>\n        </div>\n        <div class=\"d-flex gap-2\">\n            <a class=\"btn btn-outline-primary\" href=\"<?= site_url('admin/evolution-ia?vue=recettes') ?>\"\n                ><i class=\"bi bi-grid-3x3-gap me-1\"></i>Matrice des recettes</a\n            ><button class=\"btn btn-outline-primary\" data-bs-toggle=\"offcanvas\" data-bs-target=\"#devAccess\">\n                <i class=\"bi bi-folder-check me-1\"></i> Accès IA\n                <span class=\"badge text-bg-danger d-none\" id=\"devAccessPendingBadge\">0</span></button\n            ><button class=\"btn btn-outline-secondary\" data-bs-toggle=\"offcanvas\" data-bs-target=\"#devConfig\">\n                <i class=\"bi bi-sliders\"></i> Prompt système\n            </button>\n        </div>\n    </div>\n    <?php if(trim($systemPrompt)===''): ?>\n    <div class=\"alert alert-warning\">\n        <i class=\"bi bi-exclamation-triangle me-2\"></i>Le prompt système spécialisé n’est pas encore défini. Un prompt\n        de sécurité minimal est utilisé temporairement. Configure-le avant de confier des évolutions importantes au\n        moteur.\n    </div>\n    <?php endif; ?>\n    <div class=\"row justify-content-center\">\n        <div class=\"col-12 col-xl-9 dev-conversation-column\">\n            <div id=\"devWelcome\" class=\"text-center py-5 <?= $messages?'d-none':'' ?>\">\n                <i class=\"bi bi-braces-asterisk display-5\"></i>\n                <h2 class=\"h4 mt-3\">Que souhaites-tu faire évoluer ?</h2>\n                <p class=\"text-secondary\">\n                    Comprendre le projet, sélectionner le contexte, puis préparer une évolution contrôlée.\n                </p>\n            </div>\n            <div id=\"devConversation\" class=\"conversation dev-conversation mb-3\"></div>\n            <form id=\"devPromptForm\" class=\"prompt-box mb-3\" action=\"<?= site_url('admin/evolution-ia/stream') ?>\">\n                <?= csrf_field() ?><input\n                    type=\"hidden\"\n                    name=\"conversation_id\"\n                    id=\"devConversationId\"\n                    value=\"<?= (int)($active['id']??0) ?: '' ?>\"\n                /><textarea\n                    class=\"form-control border-0 shadow-none\"\n                    name=\"message\"\n                    rows=\"4\"\n                    required\n                    placeholder=\"Ex. : Analyse l’administration et propose une évolution pour…\"\n                ></textarea>\n                <div id=\"devProjectAutocomplete\" class=\"dev-project-autocomplete d-none\" role=\"listbox\" aria-label=\"Suggestions du projet\"></div>\n                <div class=\"d-flex flex-wrap align-items-center gap-2 mt-3\">\n                    <span id=\"devVoiceStatus\" class=\"small text-secondary text-center\"\n                        ><i class=\"bi bi-shield-check\"></i> La conversation reste en lecture seule. Une proposition doit\n                        être validée avant installation.</span\n                    >\n                    <div class=\"d-flex gap-2 ms-auto align-items-center\">\n                        <div class=\"input-group input-group-sm dev-project-selector\">\n                            <span class=\"input-group-text\"><i class=\"bi bi-folder2-open\"></i></span>\n                            <select class=\"form-select\" name=\"project_id\" id=\"devProjectSelect\" aria-label=\"Projet ciblé\">\n                                <?php foreach(($localProjects??[]) as $project): ?>\n                                <option value=\"<?= esc($project['id']) ?>\" data-type=\"<?= esc($project['type']??'generic') ?>\" <?= (($activeProjectId??'system')===$project['id'])?'selected':'' ?>><?= esc($project['name']) ?></option>\n                                <?php endforeach; ?>\n                            </select>\n                            <button class=\"btn btn-outline-secondary\" type=\"button\" data-bs-toggle=\"modal\" data-bs-target=\"#devProjectManager\" title=\"Gérer les projets\"><i class=\"bi bi-gear\"></i></button>\n                        </div>\n                        <button class=\"btn btn-outline-secondary\" type=\"button\" id=\"devVoiceButton\">\n                            <i class=\"bi bi-mic\"></i> Enregistrer un vocal</button\n                        ><button class=\"btn btn-dark px-4\">Envoyer <i class=\"bi bi-arrow-up\"></i></button>\n                    </div>\n                </div>\n            </form>\n            <div id=\"devProposalAction\" class=\"<?= $active?'':'d-none ' ?>mt-3 text-end\">\n                <form\n                    method=\"post\"\n                    action=\"<?= site_url('admin/evolution-ia/propositions/preparer') ?>\"\n                    id=\"devPrepareProposalForm\"\n                    onsubmit=\"if(!confirm('Préparer une proposition de code à partir de la dernière demande ? Aucun fichier ne sera encore modifié.')) return false; document.getElementById('devPrepareProposalButton').disabled=true; document.getElementById('devPrepareProposalButton').innerHTML='<span class=\\'spinner-border spinner-border-sm me-2\\' role=\\'status\\' aria-hidden=\\'true\\'></span>Préparation en cours…'; document.getElementById('devProposalLoading').classList.remove('d-none');\"\n                >\n                    <input\n                        type=\"hidden\"\n                        name=\"conversation_id\"\n                        value=\"<?= (int)($active['id']??0) ?>\"\n                    /><?= csrf_field() ?><button\n                        class=\"btn btn-outline-primary\"\n                        id=\"devPrepareProposalButton\"\n                        type=\"submit\"\n                    >\n                        <i class=\"bi bi-file-diff me-1\"></i> Préparer la correction / le diff\n                    </button>\n                </form>\n            </div>\n        </div>\n    </div>\n</div>\n<div class=\"modal fade\" id=\"devProjectManager\" tabindex=\"-1\" aria-labelledby=\"devProjectManagerLabel\" aria-hidden=\"true\" data-project-base=\"<?= site_url('admin/evolution-ia/projets') ?>\">\n    <div class=\"modal-dialog modal-xl\"><div class=\"modal-content\">\n        <div class=\"modal-header\"><div><h5 class=\"modal-title\" id=\"devProjectManagerLabel\"><i class=\"bi bi-folder2-open me-2\"></i>Projets & Project Brain</h5><div class=\"small text-secondary\">System reste la racine de Nexum IA. Sélectionne un projet puis analyse-le localement pour construire sa carte sémantique.</div></div><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div>\n        <div class=\"modal-body\">\n            <div class=\"table-responsive mb-4\"><table class=\"table align-middle\"><thead><tr><th>Projet</th><th>Type</th><th>Racine</th><th class=\"text-end\">Project Brain</th></tr></thead><tbody id=\"devProjectRows\">\n                <?php foreach(($localProjects??[]) as $project): ?><tr data-project-id=\"<?= esc($project['id']) ?>\"><td class=\"fw-semibold\"><?= esc($project['name']) ?></td><td><span class=\"badge text-bg-light\"><?= esc($project['type']??'generic') ?></span></td><td><code class=\"small\"><?= esc($project['root']) ?></code></td><td class=\"text-end text-nowrap\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary dev-project-analyze\" data-project-id=\"<?= esc($project['id']) ?>\" data-project-name=\"<?= esc($project['name']) ?>\"><i class=\"bi bi-cpu\"></i> Analyser</button> <button type=\"button\" class=\"btn btn-sm btn-outline-secondary dev-project-report\" data-project-id=\"<?= esc($project['id']) ?>\" title=\"Voir le dernier rapport\"><i class=\"bi bi-terminal\"></i></button><?php if(empty($project['system'])): ?> <button type=\"button\" class=\"btn btn-sm btn-outline-success dev-project-runtime\" data-project-id=\"<?= esc($project['id']) ?>\" data-project-name=\"<?= esc($project['name']) ?>\" data-live-root=\"<?= esc($project['live_root']??'') ?>\" data-preview-url=\"<?= esc($project['preview_url']??'') ?>\" data-php-target=\"<?= esc($project['php_target']??'') ?>\" data-php-binary=\"<?= esc($project['php_binary']??'') ?>\" data-php-strict=\"<?= !array_key_exists('php_strict',$project)||!empty($project['php_strict'])?'1':'0' ?>\" data-git-binary=\"<?= esc($project['git_binary']??'') ?>\" data-ssh-binary=\"<?= esc($project['ssh_binary']??'') ?>\" data-ssh-known-hosts=\"<?= esc($project['ssh_known_hosts']??'') ?>\" title=\"Relier au projet local exécutable\"><i class=\"bi bi-link-45deg\"></i></button> <?php if(!empty($project['preview_url'])): ?><a class=\"btn btn-sm btn-outline-info\" href=\"<?= esc($project['preview_url']) ?>\" target=\"_blank\" rel=\"noopener\" title=\"Visualiser le projet\"><i class=\"bi bi-eye\"></i></a><?php endif; ?> <button type=\"button\" class=\"btn btn-sm btn-outline-danger dev-project-delete\" data-project-id=\"<?= esc($project['id']) ?>\"><i class=\"bi bi-trash\"></i></button><?php endif; ?></td></tr><?php endforeach; ?>\n            </tbody></table></div>\n            <div class=\"row g-3\">\n                <div class=\"col-lg-6\"><form id=\"devProjectAddForm\" action=\"<?= site_url('admin/evolution-ia/projets') ?>\" class=\"border rounded p-3 h-100\">\n                    <?= csrf_field() ?><div class=\"fw-semibold mb-2\">Ajouter une racine locale</div><div class=\"row g-2\"><div class=\"col-12\"><input class=\"form-control\" name=\"name\" placeholder=\"Extranet V1\" required></div><div class=\"col-12\"><input class=\"form-control\" name=\"root\" placeholder=\"C:\\laragon\\www\\extranet\" required></div><div class=\"col-12 d-grid\"><button class=\"btn btn-dark\" type=\"submit\">Ajouter le projet</button></div></div><div id=\"devProjectManagerMessage\" class=\"small mt-2\"></div>\n                </form></div>\n                <div class=\"col-lg-6\"><form id=\"devProjectUploadForm\" action=\"<?= site_url('admin/evolution-ia/projets/upload') ?>\" method=\"post\" enctype=\"multipart/form-data\" class=\"border rounded p-3 h-100\">\n                    <?= csrf_field() ?><div class=\"fw-semibold mb-2\"><i class=\"bi bi-file-earmark-zip me-1\"></i>Importer un projet ZIP</div><div class=\"small text-secondary mb-2\">Extraction isolée, sans exécution du code contenu.</div><div class=\"row g-2\"><div class=\"col-12\"><input class=\"form-control\" name=\"name\" placeholder=\"Nom du projet\" required></div><div class=\"col-12\"><input class=\"form-control\" type=\"file\" name=\"project_zip\" accept=\".zip,application/zip\" required></div><div class=\"col-12 d-grid\"><button class=\"btn btn-outline-dark\" type=\"submit\">Uploader et enregistrer</button></div></div><div id=\"devProjectUploadMessage\" class=\"small mt-2\"></div>\n                </form></div>\n            </div>\n            <div id=\"devFrameworkMigrationPanel\" class=\"border rounded p-3 mt-4\">\n                <div class=\"d-flex align-items-center justify-content-between gap-2 mb-2\"><div><div class=\"fw-semibold\"><i class=\"bi bi-boxes me-1\"></i>Migration vers un framework PHP</div><div class=\"small text-secondary\">Installation Composer dans un workspace isolé. Le projet d’origine n’est jamais modifié pendant la préparation.</div></div><span class=\"badge text-bg-light\">Expérimental sécurisé</span></div>\n                <form id=\"devFrameworkMigrationForm\" class=\"row g-2\">\n                    <?= csrf_field() ?>\n                    <div class=\"col-lg-4\"><select class=\"form-select\" name=\"project_id\" id=\"devFrameworkProject\" required><option value=\"\">Choisir un projet externe…</option><?php foreach(($localProjects??[]) as $project): if(!empty($project['system'])) continue; ?><option value=\"<?= esc($project['id']) ?>\"><?= esc($project['name']) ?> · <?= esc($project['type']??'generic') ?></option><?php endforeach; ?></select></div>\n                    <div class=\"col-lg-3\"><select class=\"form-select\" name=\"framework\" id=\"devFrameworkTarget\" required><option value=\"codeigniter4\">CodeIgniter 4</option><option value=\"laravel\">Laravel</option><option value=\"symfony\">Symfony</option></select></div>\n                    <div class=\"col-lg-3\"><input class=\"form-control\" name=\"composer_binary\" id=\"devFrameworkComposer\" value=\"composer\" placeholder=\"composer ou chemin composer.bat\"></div>\n                    <div class=\"col-lg-2 d-grid\"><button type=\"button\" class=\"btn btn-outline-primary\" id=\"devFrameworkPlan\">Analyser</button></div>\n                    <div class=\"col-12\"><div id=\"devFrameworkPlanResult\" class=\"small border rounded p-3 bg-body-tertiary d-none\"></div></div>\n                    <div class=\"col-12 d-grid\"><button type=\"submit\" class=\"btn btn-primary\" id=\"devFrameworkPrepare\" disabled><i class=\"bi bi-shield-check me-1\"></i>Préparer la migration dans un workspace isolé</button></div>\n                    <div class=\"col-12\"><div id=\"devFrameworkMessage\" class=\"small\"></div></div>\n                </form>\n            </div>\n            <div id=\"devProjectRuntimePanel\" class=\"border rounded p-3 mt-4 d-none\">\n                <div class=\"fw-semibold mb-2\"><i class=\"bi bi-link-45deg me-1\"></i>Relier le workspace au projet local réel</div>\n                <form id=\"devProjectRuntimeForm\"><input type=\"hidden\" name=\"project_id\" id=\"devRuntimeProjectId\"><?= csrf_field() ?>\n                    <div class=\"small text-secondary mb-2\" id=\"devRuntimeProjectName\"></div>\n                    <div class=\"row g-2\"><div class=\"col-lg-7\"><label class=\"form-label small\">Racine locale exécutable</label><input class=\"form-control\" name=\"live_root\" id=\"devRuntimeLiveRoot\" placeholder=\"C:\\laragon\\www\\extranet\" required></div>\n                    <div class=\"col-lg-5\"><label class=\"form-label small\">URL de prévisualisation</label><input class=\"form-control\" name=\"preview_url\" id=\"devRuntimePreviewUrl\" placeholder=\"http://extranet.test/\"></div></div>\n                    <div class=\"row g-2 mt-1\"><div class=\"col-lg-2\"><label class=\"form-label small\">PHP cible</label><input class=\"form-control\" name=\"php_target\" id=\"devRuntimePhpTarget\" placeholder=\"7.3\"></div><div class=\"col-lg-8\"><label class=\"form-label small\">Binaire PHP cible</label><input class=\"form-control\" name=\"php_binary\" id=\"devRuntimePhpBinary\" placeholder=\"C:\\laragon\\bin\\php\\php-7.3.29-Win32-vc15-x64\\php.exe\"></div><div class=\"col-lg-2 d-flex align-items-end\"><div class=\"form-check mb-2\"><input class=\"form-check-input\" type=\"checkbox\" name=\"php_strict\" value=\"1\" id=\"devRuntimePhpStrict\" checked><label class=\"form-check-label small\" for=\"devRuntimePhpStrict\">Mode strict</label></div></div></div>\n                    <div class=\"row g-2 mt-1\"><div class=\"col-12\"><label class=\"form-label small\">Binaire Git <span class=\"text-secondary\">(facultatif : détection automatique Git for Windows)</span></label><input class=\"form-control\" name=\"git_binary\" id=\"devRuntimeGitBinary\" placeholder=\"C:\\Program Files\\Git\\cmd\\git.exe\"></div></div>\n                    <div class=\"row g-2 mt-1\"><div class=\"col-lg-6\"><label class=\"form-label small\">Binaire SSH <span class=\"text-secondary\">(OpenSSH Windows)</span></label><input class=\"form-control\" name=\"ssh_binary\" id=\"devRuntimeSshBinary\" placeholder=\"C:\\Windows\\System32\\OpenSSH\\ssh.exe\"></div><div class=\"col-lg-6\"><label class=\"form-label small\">SSH known_hosts</label><input class=\"form-control\" name=\"ssh_known_hosts\" id=\"devRuntimeSshKnownHosts\" placeholder=\"C:\\Users\\tgensou\\.ssh\\known_hosts\"></div></div>\n                    <div class=\"d-flex align-items-center gap-2 mt-3\"><button class=\"btn btn-success\" type=\"submit\"><i class=\"bi bi-shield-check me-1\"></i>Enregistrer la liaison sécurisée</button><span class=\"small\" id=\"devRuntimeMessage\"></span></div>\n                    <div class=\"small text-secondary mt-2\">Evolution prépare dans le workspace. Une proposition validée pourra ensuite être déployée uniquement sur cette cible, avec contrôle SHA-256, snapshot obligatoire et rollback.</div>\n                </form>\n            </div>\n            <div id=\"devProjectAnalysisPanel\" class=\"dev-project-analysis d-none mt-4\">\n                <div class=\"d-flex align-items-center justify-content-between gap-2 mb-2\"><div class=\"fw-semibold\"><i class=\"bi bi-cpu me-1\"></i>Project Brain · Analyse locale</div><span class=\"badge text-bg-dark\" id=\"devProjectAnalysisStatus\">Prêt</span></div>\n                <pre id=\"devProjectAnalysisTerminal\" class=\"dev-project-analysis-terminal\" aria-live=\"polite\"></pre>\n            </div>\n        </div>\n    </div></div>\n</div>\n<div\n    class=\"offcanvas offcanvas-end\"\n    tabindex=\"-1\"\n    id=\"devAccess\"\n    style=\"--bs-offcanvas-width: min(760px, 96vw)\"\n    aria-labelledby=\"devAccessLabel\"\n>\n    <div class=\"offcanvas-header\">\n        <div>\n            <h5 class=\"offcanvas-title\" id=\"devAccessLabel\">\n                <i class=\"bi bi-shield-lock me-2\"></i>AI Access & Context Manager\n            </h5>\n            <div class=\"small text-secondary\">Autorise ce que Nexum IA peut lire, sans modifier le code.</div>\n        </div>\n        <button class=\"btn-close\" data-bs-dismiss=\"offcanvas\"></button>\n    </div>\n    <div class=\"offcanvas-body\">\n        <div class=\"alert alert-info small\">\n            <strong>Protections absolues :</strong> <code>.env*</code>, <code>.git</code>, <code>.ssh</code>,\n            <code>writable</code>, <code>public/uploads</code>, <code>vendor</code> et les données BDD ne sont jamais\n            exposés. Pour la BDD, seul le schéma structurel peut être autorisé.\n        </div>\n        <div class=\"d-flex flex-wrap gap-2 align-items-end mb-4\">\n            <div>\n                <label class=\"form-label small fw-semibold\" for=\"devAccessMode\">Mode d’exploration</label\n                ><select class=\"form-select form-select-sm\" id=\"devAccessMode\">\n                    <option value=\"assisted\">Assisté — contexte de base automatique, nouveaux accès soumis</option>\n                    <option value=\"strict\">Strict — tout nouvel accès structuré soumis</option>\n                </select>\n            </div>\n            <button class=\"btn btn-sm btn-outline-secondary\" id=\"devAccessRefresh\">\n                <i class=\"bi bi-arrow-clockwise\"></i> Actualiser</button\n            ><button class=\"btn btn-sm btn-primary ms-auto d-none\" id=\"devAccessResume\">\n                <i class=\"bi bi-play-fill\"></i> Reprendre l’analyse\n            </button>\n        </div>\n        <section class=\"mb-4\">\n            <h6 class=\"d-flex align-items-center gap-2\">\n                <i class=\"bi bi-inbox\"></i> Demandes d’accès IA en attente\n                <span class=\"badge text-bg-secondary\" id=\"devAccessPendingCount\">0</span>\n            </h6>\n            <div id=\"devAccessPending\" class=\"small text-secondary\">Aucune demande en attente.</div>\n            <div class=\"d-flex flex-wrap gap-2 mt-2 d-none\" id=\"devAccessPendingActions\">\n                <select class=\"form-select form-select-sm w-auto\" id=\"devAccessPendingScope\">\n                    <option value=\"once\">Une lecture</option>\n                    <option value=\"conversation\">Cette conversation</option>\n                    <option value=\"persistent\">Toujours</option></select\n                ><button class=\"btn btn-sm btn-success\" data-dev-access-resolve=\"allow\">\n                    <i class=\"bi bi-check2\"></i> Autoriser</button\n                ><button class=\"btn btn-sm btn-outline-danger\" data-dev-access-resolve=\"deny\">\n                    <i class=\"bi bi-x-lg\"></i> Refuser\n                </button>\n            </div>\n        </section>\n        <section class=\"mb-4\">\n            <h6><i class=\"bi bi-box-arrow-in-down me-1\"></i> Contexte autorisé prêt</h6>\n            <div id=\"devAccessContextReady\" class=\"small text-secondary\">Aucune ressource autorisée à injecter.</div>\n        </section>\n        <section class=\"mb-4\">\n            <div class=\"d-flex justify-content-between align-items-center\">\n                <h6><i class=\"bi bi-diagram-3 me-1\"></i> Arborescence autorisable</h6>\n                <div class=\"d-flex gap-2\">\n                    <select class=\"form-select form-select-sm\" id=\"devAccessTreeScope\">\n                        <option value=\"once\">Une lecture</option>\n                        <option value=\"conversation\">Conversation</option>\n                        <option value=\"persistent\">Toujours</option></select\n                    ><button class=\"btn btn-sm btn-outline-primary\" id=\"devAccessGrantTree\">\n                        <i class=\"bi bi-check-square\"></i> Autoriser la sélection\n                    </button>\n                </div>\n            </div>\n            <div\n                id=\"devAccessTree\"\n                class=\"border rounded p-2 bg-body-tertiary\"\n                style=\"max-height: 42vh; overflow: auto\"\n            ></div>\n        </section>\n        <section class=\"mb-4\">\n            <h6><i class=\"bi bi-key me-1\"></i> Autorisations actives</h6>\n            <div id=\"devAccessGrants\" class=\"small text-secondary\">Aucune autorisation enregistrée.</div>\n        </section>\n        <section>\n            <h6><i class=\"bi bi-clock-history me-1\"></i> Journal d’accès</h6>\n            <div id=\"devAccessLogs\" class=\"small text-secondary\" style=\"max-height: 220px; overflow: auto\">\n                Aucun accès journalisé.\n            </div>\n        </section>\n    </div>\n</div>\n<div\n    class=\"modal fade\"\n    id=\"devAccessPromptModal\"\n    tabindex=\"-1\"\n    aria-labelledby=\"devAccessPromptTitle\"\n    aria-hidden=\"true\"\n>\n    <div class=\"modal-dialog modal-dialog-centered modal-lg\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\">\n                <div>\n                    <h5 class=\"modal-title\" id=\"devAccessPromptTitle\">\n                        <i class=\"bi bi-shield-check me-2\"></i>Nexum IA demande un accès en lecture\n                    </h5>\n                    <div class=\"small text-secondary\">\n                        Ces ressources sont nécessaires pour poursuivre l’analyse technique.\n                    </div>\n                </div>\n            </div>\n            <div class=\"modal-body\">\n                <div id=\"devAccessPromptList\" class=\"small\"></div>\n                <div class=\"alert alert-light border small mb-0 mt-3\">\n                    <i class=\"bi bi-info-circle me-1\"></i>L’autorisation vaut uniquement pour cette conversation. Aucun\n                    droit d’écriture n’est accordé.\n                </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" class=\"btn btn-outline-danger\" id=\"devAccessPromptDeny\">\n                    <i class=\"bi bi-x-lg\"></i> Refuser</button\n                ><button type=\"button\" class=\"btn btn-success\" id=\"devAccessPromptAllow\">\n                    <i class=\"bi bi-check2\"></i> Autoriser la lecture et continuer\n                </button>\n            </div>\n        </div>\n    </div>\n</div>\n<div class=\"offcanvas offcanvas-end\" tabindex=\"-1\" id=\"devConfig\" style=\"--bs-offcanvas-width:680px\">\n    <div class=\"offcanvas-header\">\n        <h5 class=\"offcanvas-title\">Réglages · Agents IA</h5>\n        <button class=\"btn-close\" data-bs-dismiss=\"offcanvas\"></button>\n    </div>\n    <div class=\"offcanvas-body\">\n        <form method=\"post\" action=\"<?= site_url('admin/evolution-ia/prompt') ?>\" id=\"configuration\">\n            <?= csrf_field() ?>\n            <div class=\"card border-0 bg-light mb-4\"><div class=\"card-body\">\n                <div class=\"d-flex align-items-center justify-content-between mb-2\"><strong>IA Super Admin · Mammouth</strong><span class=\"badge text-bg-secondary\">Orchestrateur</span></div>\n                <p class=\"small text-secondary\">Elle reçoit ta demande avant le Development Engine, réduit le scope et contrôle sa réponse. La clé reste exclusivement dans <code>MAMMOUTH_API_KEY</code>.</p>\n                <div class=\"form-check form-switch mb-3\"><input class=\"form-check-input\" type=\"checkbox\" name=\"development_admin_enabled\" value=\"1\" id=\"devAdminEnabled\" <?= $adminEnabled?'checked':'' ?>><label class=\"form-check-label\" for=\"devAdminEnabled\">Activer l’IA Super Admin</label></div>\n                <label class=\"form-label small fw-semibold\">Modèle Mammouth par défaut</label>\n                <input class=\"form-control font-monospace mb-2\" name=\"development_admin_model\" list=\"mammouthModels\" value=\"<?= esc($adminModel) ?>\" placeholder=\"mammouth-recommended\">\n                <datalist id=\"mammouthModels\"><?php foreach(($mammouthModels ?? []) as $model): ?><option value=\"<?= esc($model) ?>\"><?php endforeach; ?></datalist>\n                <div class=\"row g-2 mb-3\">\n                    <?php foreach(['conversation_title'=>'Titre conversation','admin_prepare'=>'Préparation Admin','proposal_preflight'=>'Préflight proposition','admin_review'=>'Review Admin'] as $phase=>$label): ?>\n                    <div class=\"col-md-6\"><label class=\"form-label small mb-1\"><?= esc($label) ?></label><input class=\"form-control form-control-sm font-monospace\" name=\"development_mammouth_model_<?= esc($phase) ?>\" list=\"mammouthModels\" value=\"<?= esc($mammouthPhaseModels[$phase] ?? $adminModel) ?>\"></div>\n                    <?php endforeach; ?>\n                </div>\n                <label class=\"form-label small fw-semibold\">Prompt système de l’IA Super Admin</label>\n                <textarea class=\"form-control font-monospace\" rows=\"13\" name=\"development_admin_system_prompt\"><?= esc($adminSystemPrompt) ?></textarea>\n            </div></div>\n            <div class=\"card border-0 bg-light\"><div class=\"card-body\">\n                <div class=\"d-flex align-items-center justify-content-between mb-2\"><strong>Development Engine · OpenAI</strong><span class=\"badge text-bg-dark\">Développeur</span></div>\n                <p class=\"small text-secondary\">Ce prompt reste indépendant de l’IA Admin. Le contexte technique dynamique est ajouté automatiquement.</p>\n                <label class=\"form-label small fw-semibold\">Prompt système du Development Engine</label>\n                <textarea class=\"form-control font-monospace\" rows=\"16\" name=\"development_system_prompt\"><?= esc($systemPrompt) ?></textarea>\n            </div></div>\n            <button class=\"btn btn-dark w-100 mt-3\"><i class=\"bi bi-check-lg\"></i> Enregistrer les réglages IA</button>\n        </form>\n\n        <hr class=\"my-4\">\n        <div class=\"d-flex align-items-center justify-content-between mb-2\">\n            <strong>Journal d’utilisation IA</strong>\n            <span class=\"badge text-bg-light border\"><?= (int)($aiUsageTotals['calls'] ?? 0) ?> appels</span>\n        </div>\n        <p class=\"small text-secondary mb-3\">Mesure locale des appels OpenAI et Mammouth. Les clés API et le contenu des prompts ne sont jamais enregistrés ici.</p>\n        <div class=\"row g-2 mb-3 small\">\n            <div class=\"col-6\"><div class=\"border rounded-3 p-2\"><span class=\"text-secondary\">Entrée</span><br><strong><?= number_format((int)($aiUsageTotals['input'] ?? 0),0,',',' ') ?></strong></div></div>\n            <div class=\"col-6\"><div class=\"border rounded-3 p-2\"><span class=\"text-secondary\">Sortie</span><br><strong><?= number_format((int)($aiUsageTotals['output'] ?? 0),0,',',' ') ?></strong></div></div>\n            <div class=\"col-6\"><div class=\"border rounded-3 p-2\"><span class=\"text-secondary\">Cache OpenAI</span><br><strong><?= number_format((int)($aiUsageTotals['cached'] ?? 0),0,',',' ') ?></strong></div></div>\n            <div class=\"col-6\"><div class=\"border rounded-3 p-2\"><span class=\"text-secondary\">Raisonnement</span><br><strong><?= number_format((int)($aiUsageTotals['reasoning'] ?? 0),0,',',' ') ?></strong></div></div>\n        </div>\n        <div class=\"table-responsive\" style=\"max-height:360px\">\n            <table class=\"table table-sm align-middle small\">\n                <thead class=\"sticky-top bg-body\"><tr><th>Heure</th><th>Agent</th><th>Phase</th><th>Modèle</th><th class=\"text-end\">Tokens</th><th class=\"text-end\">Durée</th></tr></thead>\n                <tbody>\n                <?php foreach(($aiUsageLogs ?? []) as $log): ?>\n                    <tr>\n                        <td class=\"text-nowrap\"><?= esc(date('d/m H:i:s',strtotime((string)$log['created_at']))) ?></td>\n                        <td><span class=\"badge <?= $log['provider']==='mammouth'?'text-bg-warning':'text-bg-dark' ?>\"><?= esc(ucfirst((string)$log['provider'])) ?></span></td>\n                        <td><?= esc((string)$log['phase']) ?></td>\n                        <td title=\"Demandé : <?= esc((string)($log['requested_model'] ?? '')) ?>\"><?= esc((string)($log['actual_model'] ?: $log['requested_model'])) ?></td>\n                        <td class=\"text-end text-nowrap\"><?= number_format((int)$log['input_tokens'],0,',',' ') ?> + <?= number_format((int)$log['output_tokens'],0,',',' ') ?><?php if((int)$log['cached_tokens']>0): ?><br><span class=\"text-secondary\">cache <?= number_format((int)$log['cached_tokens'],0,',',' ') ?></span><?php endif ?></td>\n                        <td class=\"text-end text-nowrap\"><?= $log['duration_ms']!==null ? number_format(((int)$log['duration_ms'])/1000,2,',',' ').' s' : '—' ?></td>\n                    </tr>\n                <?php endforeach ?>\n                <?php if(empty($aiUsageLogs)): ?><tr><td colspan=\"6\" class=\"text-center text-secondary py-3\">Aucun appel journalisé. Exécute la migration puis lance une demande.</td></tr><?php endif ?>\n                </tbody>\n            </table>\n        </div>\n    </div>\n</div>\n<div id=\"devProposalLoading\" class=\"dev-proposal-loading d-none\" role=\"status\" aria-live=\"polite\" aria-busy=\"true\">\n    <canvas id=\"devProposalMatrix\" class=\"dev-proposal-matrix\" aria-hidden=\"true\"></canvas>\n    <div class=\"dev-proposal-loading-card\">\n        <div class=\"spinner-border text-primary mb-3\" aria-hidden=\"true\"></div>\n        <h2 class=\"h5 mb-2\">Préparation de la proposition</h2>\n        <p class=\"text-secondary mb-0\">\n            evolution explore le contexte technique et prépare le diff. Cette opération peut prendre quelques instants.\n        </p>\n    </div>\n</div>\n<style>\n    .dev-engine .dev-conversation-column {\n        position: relative;\n    }\n    /* Sans conversation, le conteneur vide ne doit pas repousser le composer en bas. */\n    .dev-engine:not(.is-engaged) .dev-conversation:empty {\n        min-height: 0;\n        height: 0;\n        padding: 0;\n        margin: 0 !important;\n        overflow: hidden;\n    }\n    .dev-conversation .msg {\n        width: 100%;\n        max-width: none;\n    }\n    .prompt-box {\n        width: 100%;\n        \n    }\n    .dev-engine.is-engaged .prompt-box {\n        position: relative;\n        width: 100%;\n        z-index: 3;\n    }\n    .dev-engine.is-engaged .dev-conversation {\n        margin-bottom: 1rem !important;\n    }\n    .prompt-box #devVoiceStatus {\n        background: rgba(0, 0, 0, 0.05);\n        position: absolute;\n        width: 100%;\n        padding: 5px;\n        margin: -18px;\n        bottom: 19px;\n        border-radius: 0 0 15px 15px;\n    }\n    .prompt-box .d-flex.gap-2.ms-auto {\n        margin-bottom: 25px;\n    }\n    .dev-proposal-loading {\n        position: fixed;\n        inset: 0;\n        z-index: 2000;\n        background: rgba(13, 31, 51, 0.48);\n        backdrop-filter: blur(4px);\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        padding: 1.5rem;\n    }\n    .dev-proposal-matrix {\n        position: absolute;\n        inset: 0;\n        width: 100%;\n        height: 100%;\n        z-index: 0;\n        pointer-events: none;\n        background: #000;\n    }\n    .dev-proposal-loading-card {\n        position: relative;\n        z-index: 1;\n        max-width: 430px;\n        width: 100%;\n        padding: 2rem;\n        text-align: center;\n        border-radius: 1rem;\n        background: #fff;\n        box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);\n    }\n    .dev-history {\n        margin-left: -1rem;\n        margin-right: -1rem;\n    }\n    .dev-history-list {\n        overflow: visible;\n    }\n    .dev-history .min-w-0 {\n        min-width: 0;\n    }\n    .dev-history-item {\n        width: 100%;\n        position: relative;\n        margin-bottom: 0.35rem;\n    }\n    .dev-history .list-group-item {\n        border: 1px solid rgba(0, 0, 0, 0.125) !important;\n        border-radius: 0.75rem !important;\n    }\n    .dev-history .list-group-item.active {\n        z-index: auto;\n        color: inherit;\n        font-weight: 700;\n        background-color: rgba(13, 110, 253, 0.14);\n        border-color: transparent;\n        box-shadow: inset 4px 0 0 #0d6efd;\n    }\n    .dev-history form {\n        margin: 0;\n        opacity: 0;\n        pointer-events: none;\n        position: absolute;\n        top: 50%;\n        right: 0.75rem;\n        z-index: 2;\n        transform: translateY(-50%);\n        transition: opacity 0.15s ease-in-out;\n    }\n    .dev-history form .btn {\n        display: grid;\n        width: 2rem;\n        height: 2rem;\n        padding: 0;\n        place-items: center;\n        border: 0;\n        border-radius: 50%;\n        color: #dc3545;\n        background: rgba(220, 53, 69, 0.1);\n        transition: transform 0.15s ease, background-color 0.15s ease, color 0.15s ease;\n    }\n    .dev-history form .btn:hover,\n    .dev-history form .btn:focus-visible {\n        color: #fff;\n        background: #dc3545;\n        transform: scale(1.08);\n    }\n    .dev-history-item:hover form,\n    .dev-history-item:focus-within form {\n        opacity: 1;\n        pointer-events: auto;\n    }\n    @media (max-width: 767.98px) {\n        .dev-engine.is-engaged .dev-conversation {\n            margin-bottom: 1rem !important;\n        }\n    }\n</style>\n<script>\n    window.evolutionDevInitial=<?= json_encode(array_map(static fn($m)=>['role'=>$m['role'],'content'=>$m['content']],$messages),JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT) ?>;window.evolutionDevAccess={conversationId:<?= (int)($active['id']??0) ?>,csrfName:<?= json_encode(csrf_token()) ?>,csrfHash:<?= json_encode(csrf_hash()) ?>,tree:<?= json_encode(site_url('admin/evolution-ia/acces/arbre')) ?>,state:<?= json_encode(site_url('admin/evolution-ia/acces/etat')) ?>,grant:<?= json_encode(site_url('admin/evolution-ia/acces/autoriser')) ?>,resolve:<?= json_encode(site_url('admin/evolution-ia/acces/resoudre')) ?>,mode:<?= json_encode(site_url('admin/evolution-ia/acces/mode')) ?>,resume:<?= json_encode(site_url('admin/evolution-ia/acces/reprendre')) ?>,revokeBase:<?= json_encode(site_url('admin/evolution-ia/acces')) ?>};\n</script>\n<?= $this->endSection() ?> <?= $this->section('scripts') ?>\n<script src=\"<?= base_url('assets/js/proposal-matrix.js') ?>\"></script>\n<script src=\"<?= base_url('assets/js/development-engine.js') ?>\"></script>\n<script src=\"<?= base_url('assets/js/development-access.js') ?>\"></script>\n<script src=\"<?= base_url('assets/js/development-voice.js') ?>\"></script>\n<?= $this->endSection() ?>\n\n--- END PINNED app/Views/admin/development/index.php | COMPLET ---\n\n=== CONTEXTE SECONDAIRE — DEPENDANCES RESOLUES ===\n\n--- CONTEXT EXCERPT app/Config/Routes.php | 4784 octets | sha256=1a90cde137caa1eebf7be26da223f0fa2b7988d319b489c5ae4e7b3eda6dc5a9 | NON_COMPLET ---\n<?php\nuse CodeIgniter\\Router\\RouteCollection;\n/** @var RouteCollection $routes */\n$routes->setDefaultNamespace('App\\\\Controllers');\n$routes->setAutoRoute(false);\n\n// Site public Nexum AI. L'espace de travail reste sous /admin/evolution-ia.\n$routes->get('/', 'Home::index');\n\n// Alias de connexion explicite, hors du namespace des contrôleurs Admin.\n$routes->get('admin/auth', 'Auth::login');\n$routes->get('auth/login', 'Auth::login');\n$routes->post('auth/login', 'Auth::attempt');\n$routes->get('auth/register', 'Auth::register');\n$routes->post('auth/register', 'Auth::store');\n$routes->get('auth/forgot-password', 'Auth::forgotPassword');\n$routes->post('auth/forgot-password', 'Auth::sendResetLink');\n$routes->get('auth/reset-password/(:segment)', 'Auth::resetPassword/$1');\n$routes->post('auth/reset-password/(:segment)', 'Auth::updatePassword/$1');\n$routes->post('auth/logout', 'Auth::logout');\n\n$routes->group('admin', ['namespace'=>'App\\\\Controllers\\\\Admin'], static function($routes){\n    $routes->get('evolution-ia', 'Development::index');\n    $routes->post('evolution-ia/stream', 'Development::stream');\n    $routes->post('evolution-ia/transcrire', 'DevelopmentVoice::transcribe');\n    $routes->post('evolution-ia/prompt', 'Development::savePrompt');\n    $routes->post('evolution-ia/systeme/exporter', 'SystemUpdates::export');\n    $routes->post('evolution-ia/systeme/analyser', 'SystemUpdates::analyze');\n    $routes->post('evolution-ia/systeme/mettre-a-jour', 'SystemUpdates::install');\n    $routes->post('evolution-ia/systeme/restaurer', 'SystemUpdates::rollback');\n    $routes->get('evolution-ia/systeme/historique', 'SystemUpdates::history');\n    $routes->get('evolution-ia/systeme/recuperation', 'SystemUpdates::recovery');\n    $routes->post('evolution-ia/apparence', 'ThemeSettings::save');\n    $routes->get('evolution-ia/autocomplete', 'Development::autocomplete');\n    $routes->post('evolution-ia/projets', 'Development::projectSave');\n    $routes->post('evolution-ia/projets/(:segment)/supprimer', 'Development::projectDelete/$1');\n    $routes->post('evolution-ia/projets/upload', 'Development::projectUpload');\n    $routes->post('evolution-ia/projets/(:segment)/runtime', 'Development::projectRuntime/$1');\n    $routes->post('evolution-ia/projets/(:segment)/analyser', 'Development::projectAnalyze/$1');\n    $routes->get('evolution-ia/projets/(:segment)/rapport', 'Development::projectReport/$1');\n    $routes->post('evolution-ia/projets/(:segment)/framework/plan', 'Development::frameworkPlan/$1');\n    $routes->post('evolution-ia/projets/(:segment)/framework/preparer', 'Development::frameworkPrepare/$1');\n    $routes->get('evolution-ia/acces/arbre', 'Development::accessTree');\n    $routes->get('evolution-ia/acces/etat', 'Development::accessState');\n    $routes->post('evolution-ia/acces/autoriser', 'Development::accessGrant');\n    $routes->post('evolution-ia/acces/resoudre', 'Development::accessResolve');\n    $routes->post('evolution-ia/acces/(:num)/revoquer', 'Development::accessRevoke/$1');\n    $routes->post('evolution-ia/acces/mode', 'Development::accessMode');\n    $routes->post('evolution-ia/acces/reprendre', 'Development::resumeAccess');\n    $routes->post('evolution-ia/propositions/preparer', 'Development::prepareProposal');\n    $routes->get('evolution-ia/propositions/(:num)', 'Development::proposal/$1');\n    $routes->get('evolution-ia/logs', 'Development::logs');\n    $routes->get('evolution-ia/logs/context/(:num)/(:segment)', 'Development::downloadAiContext/$1/$2');\n    $routes->post('evolution-ia/propositions/(:num)/installer', 'Development::installProposal/$1');\n    $routes->post('evolution-ia/propositions/(:num)/refuser', 'Development::rejectProposal/$1');\n    $routes->post('evolution-ia/propositions/(:num)/deployer', 'Development::deployProposal/$1');\n    $routes->post('evolution-ia/propositions/(:num)/rollback-deploiement', 'Development::rollbackDeployment/$1');\n    $routes->post('evolution-ia/propositions/(:num)/git-add', 'Development::gitAdd/$1');\n    $routes->post('evolution-ia/propositions/(:num)/git-commit', 'Development::gitCommit/$1');\n    $routes->post('evolution-ia/propositions/(:num)/git-diagnostic', 'Development::gitDiagnose/$1');\n    $routes->post('evolution-ia/propositions/(:num)/git-push', 'Development::gitPush/$1');\n    $routes->post('evolution-ia/propositions/(:num)/git-revert', 'Development::gitRevert/$1');\n    $routes->post('evolution-ia/propositions/(:num)/git-revert-push', 'Development::gitPushRevert/$1');\n    $routes->post('evolution-ia/propositions/(:num)/restaurer', 'Development::restoreProposal/$1');\n    $routes->get('evolution-ia/propositions/(:num)/backup-bdd', 'Development::downloadDatabaseBackup/$1');\n    $routes->post('evolution-ia/(:num)/supprimer', 'Development::delete/$1');\n});\n\n--- END CONTEXT EXCERPT app/Config/Routes.php ---\n\n--- CONTEXT EXCERPT app/Controllers/Auth.php | 4032 octets | sha256=4fa22585a278937ae601e3bf21af5a0a186d6333c8e41af378ba357019bc0b1a | NON_COMPLET ---\n<?php\nnamespace App\\Controllers;\n\nuse App\\Models\\UserModel;\n\nclass Auth extends BaseController\n{\n    public function login()\n    {\n        if ((int) session('user_id') > 0) {\n            return redirect()->to(site_url('admin/evolution-ia'));\n        }\n        return view('auth/form', ['mode' => 'login', 'title' => 'Connexion']);\n    }\n    public function register(){ return view('auth/form',['mode'=>'register','title'=>'Créer un compte']); }\n    public function forgotPassword(){ return view('auth/form',['mode'=>'forgot','title'=>'Mot de passe oublié']); }\n\n    public function attempt(){\n        $user=(new UserModel())->where('email',strtolower(trim((string)$this->request->getPost('email'))))->first();\n        if(!$user || !password_verify((string)$this->request->getPost('password'),(string)$user['password_hash'])) return redirect()->back()->withInput()->with('error','Identifiants invalides.');\n        session()->regenerate();\n        session()->set(['user_id'=>(int)$user['id'],'user_name'=>$user['name'],'is_admin'=>(int)$user['is_admin']]);\n        $target=(string)(session('auth_redirect')?:site_url('admin/evolution-ia')); session()->remove('auth_redirect');\n        return redirect()->to($target);\n    }\n\n    public function store(){\n        $name=trim((string)$this->request->getPost('name')); $email=strtolower(trim((string)$this->request->getPost('email'))); $password=[EXPURGÉ]\n        if($name==='' || !filter_var($email,FILTER_VALIDATE_EMAIL) || strlen($password)<12) return redirect()->back()->withInput()->with('error','Nom, e-mail valide et mot de passe de 12 caractères minimum requis.');\n        $users=new UserModel(); if($users->where('email',$email)->first()) return redirect()->back()->withInput()->with('error','Cet e-mail est déjà enregistré.');\n        $id=$users->insert(['name'=>$name,'email'=>$email,'password_hash'=>password_hash($password,PASSWORD_DEFAULT),'is_admin'=>1]);\n        session()->regenerate(); session()->set(['user_id'=>(int)$id,'user_name'=>$name,'is_admin'=>1]);\n        return redirect()->to(site_url('admin/evolution-ia'));\n    }\n\n    public function sendResetLink(){\n        $email=strtolower(trim((string)$this->request->getPost('email'))); $user=(new UserModel())->where('email',$email)->first();\n        if($user){\n            $token=bin2hex(random_bytes(32));\n            db_connect()->table('password_resets')->where('user_id',(int)$user['id'])->delete();\n            db_connect()->table('password_resets')->insert(['user_id'=>(int)$user['id'],'token_hash'=>hash('sha256',$token),'expires_at'=>date('Y-m-d H:i:s',time()+3600),'created_at'=>date('Y-m-d H:i:s')]);\n            $url=site_url('auth/reset-password/'.$token);\n            service('email')->setTo($email)->setSubject('Réinitialisation du mot de passe')->setMessage(\"Utilisez ce lien dans l’heure : $url\")->send();\n        }\n        return redirect()->to(site_url('auth/login'))->with('success','Si cette adresse existe, un lien de réinitialisation a été envoyé.');\n    }\n\n    public function resetPassword(string $token){ return view('auth/form',['mode'=>'reset','title'=>'Nouveau mot de passe','token'=>$token]); }\n    public function updatePassword(string $token){\n        $password=[EXPURGÉ] $reset=db_connect()->table('password_resets')->where('token_hash',hash('sha256',$token))->where('expires_at >=',date('Y-m-d H:i:s'))->get()->getRowArray();\n        if(!$reset || strlen($password)<12) return redirect()->back()->with('error','Lien invalide ou expiré, ou mot de passe trop court.');\n        (new UserModel())->update((int)$reset['user_id'],['password_hash'=>password_hash($password,PASSWORD_DEFAULT)]);\n        db_connect()->table('password_resets')->where('id',(int)$reset['id'])->delete();\n        return redirect()->to(site_url('auth/login'))->with('success','Mot de passe modifié. Connectez-vous.');\n    }\n    public function logout(){ session()->destroy(); return redirect()->to(site_url('auth/login')); }\n}\n\n--- END CONTEXT EXCERPT app/Controllers/Auth.php ---\n\n--- CONTEXT EXCERPT app/Views/auth/form.php | 28966 octets | sha256=819a115653a9e3d92bf02e7cd107c31a74d310d8523a815d6bdf1e81b5b06ac4 | NON_COMPLET ---\n<!doctype html>\n<html lang=\"fr\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta name=\"robots\" content=\"noindex, nofollow, noarchive, nosnippet, noimageindex\" />\n    <meta name=\"googlebot\" content=\"noindex, nofollow, noarchive, nosnippet, noimageindex\" />\n    <meta name=\"bingbot\" content=\"noindex, nofollow, noarchive, nosnippet, noimageindex\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"<?= base_url('assets/img/favicon-96x96.png') ?>\" sizes=\"96x96\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"<?= base_url('assets/img/favicon.svg') ?>\" />\n    <link rel=\"shortcut icon\" href=\"<?= base_url('assets/img/favicon.ico') ?>\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"<?= base_url('assets/img/apple-touch-icon.png') ?>\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"Nexum AI\" />\n    <link rel=\"manifest\" href=\"<?= base_url('assets/img/site.webmanifest') ?>\" />\n    <title><?= esc($title) ?> | Nexum AI</title>\n    <style>\n\n        @font-face {\n\n            font-family: \"Sora\";\n\n            src: url(\"<?= base_url('assets/font/sora-400-latin.woff2') ?>\") format(\"woff2\");\n\n            font-weight: 400;\n\n            font-style: normal;\n\n            font-display: swap;\n\n        }\n\n\n\n        @font-face {\n\n            font-family: \"Sora\";\n\n            src: url(\"<?= base_url('assets/font/sora-500-latin.woff2') ?>\") format(\"woff2\");\n\n            font-weight: 500;\n\n            font-style: normal;\n\n            font-display: swap;\n\n        }\n\n\n\n        @font-face {\n\n            font-family: \"Sora\";\n\n            src: url(\"<?= base_url('assets/font/sora-600-latin.woff2') ?>\") format(\"woff2\");\n\n            font-weight: 600;\n\n            font-style: normal;\n\n            font-display: swap;\n\n        }\n\n\n\n        @font-face {\n\n            font-family: \"Sora\";\n\n            src: url(\"<?= base_url('assets/font/sora-700-latin.woff2') ?>\") format(\"woff2\");\n\n            font-weight: 700;\n\n            font-style: normal;\n\n            font-display: swap;\n\n        }\n\n\n\n        @font-face {\n\n            font-family: \"NexumOCR\";\n\n            src: url(\"../assets/font/ocr-a.woff\") format(\"woff\");\n\n            font-weight: normal;\n\n            font-style: normal;\n\n            font-display: swap;\n\n        }\n\n\n\n        :root {\n\n            color-scheme: dark;\n\n        }\n\n\n\n        * {\n\n            box-sizing: border-box;\n\n        }\n\n\n\n        html,\n\n        body {\n\n            width: 100%;\n\n            min-height: 100%;\n\n            margin: 0;\n\n        }\n\n\n\n        body {\n\n            min-height: 100vh;\n\n            color: #eef0f6;\n\n            font-family: \"Sora\", system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n\n            background: #06070b;\n\n        }\n\n\n\n        @property --ci-login-angle {\n\n            syntax: \"<angle>\";\n\n            inherits: false;\n\n            initial-value: 0deg;\n\n        }\n\n\n\n        .auth-page {\n\n            --ci-login-ink: #eef0f6;\n\n            --ci-login-muted: #a2aabd;\n\n            --ci-login-line: #252a38;\n\n            --ci-login-field: #11141d;\n\n            --ci-login-violet: #a78bfa;\n\n            --ci-login-cyan: #22d3ee;\n\n            --ci-login-pink: #f472b6;\n\n            --ci-login-error: #fda4af;\n\n\n\n            position: relative;\n\n            isolation: isolate;\n\n            min-height: 100vh;\n\n            overflow: hidden;\n\n\n\n            display: grid;\n\n            grid-template-rows: 1fr auto;\n\n\n\n            background:\n\n                linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px) 0 0 / 48px 48px,\n\n                linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px) 0 0 / 48px 48px,\n\n                #06070b;\n\n        }\n\n\n\n        .auth-glow {\n\n            position: absolute;\n\n            z-index: -3;\n\n            top: -260px;\n\n            left: 50%;\n\n            width: 900px;\n\n            height: 600px;\n\n            transform: translateX(-50%);\n\n            background: radial-gradient(\n\n                closest-side,\n\n                rgba(139,92,246,.35),\n\n                rgba(34,211,238,.12) 55%,\n\n                transparent\n\n            );\n\n            pointer-events: none;\n\n        }\n\n\n\n        .nexum-dev-terminal {\n\n    position: fixed;\n\n\n\n    top: 24px;\n\n    left: 28px;\n\n\n\n    z-index: 30;\n\n\n\n    width: min(\n\n        500px,\n\n        calc(100vw - 56px)\n\n    );\n\n\n\n    pointer-events: none;\n\n\n\n    font-family:\n\n        \"NexumOCR\",\n\n        monospace;\n\n\n\n    font-size: 12px;\n\n\n\n    line-height: 1.6;\n\n\n\n    letter-spacing: .5px;\n\n\n\n    color: rgba(\n\n        103,\n\n        201,\n\n        255,\n\n        .92\n\n    );\n\n\n\n    text-shadow:\n\n        0 0 5px\n\n        rgba(0, 153, 204, .45);\n\n}\n\n\n\n\n\n.nexum-dev-terminal__label {\n\n    margin-bottom: 9px;\n\n\n\n    color: rgba(\n\n        164,\n\n        223,\n\n        255,\n\n        .50\n\n    );\n\n\n\n    font-size: 10px;\n\n\n\n    letter-spacing: 1.3px;\n\n}\n\n\n\n\n\n.nexum-dev-terminal__body {\n\n    white-space: pre-line;\n\n\n\n    transition:\n\n        opacity .22s ease;\n\n}\n\n\n\n\n\n.nexum-dev-terminal__cursor {\n\n    display: inline-block;\n\n\n\n    margin-left: 3px;\n\n\n\n    animation:\n\n        nexum-terminal-cursor\n\n        .75s\n\n        steps(1)\n\n        infinite;\n\n}\n\n\n\n\n\n@keyframes nexum-terminal-cursor {\n\n\n\n    0%,\n\n    49% {\n\n        opacity: 1;\n\n    }\n\n\n\n    50%,\n\n    100% {\n\n        opacity: 0;\n\n    }\n\n}\n\n\n\n        /*\n\n         * Zone principale.\n\n         * Le formulaire est réellement centré verticalement\n\n         * dans tout l'espace disponible au-dessus du footer.\n\n         */\n\n        .auth-content {\n\n            position: relative;\n\n            z-index: 10;\n\n            min-height: 0;\n\n            display: flex;\n\n            align-items: center;\n\n            justify-content: center;\n\n            padding: 96px 20px 48px;\n\n        }\n\n\n\n        .auth-shell {\n\n            position: relative;\n\n            z-index: 10;\n\n            width: min(440px, 100%);\n\n        }\n\n\n\n        .auth-brand {\n\n            display: flex;\n\n            align-items: center;\n\n            justify-content: center;\n\n            gap: 10px;\n\n            width: 100%;\n\n            margin-bottom: 32px;\n\n            color: #eef0f6;\n\n            font-size: 18px;\n\n            font-weight: 600;\n\n            text-decoration: none;\n\n            text-align: center;\n\n        }\n\n\n\n        .auth-brand span {\n\n            position: relative;\n\n            text-shadow: 0 1px 15px rgba(183,58,218,.9);\n\n        }\n\n\n\n        .auth-brand-mark {\n\n            width: 32px;\n\n            height: 32px;\n\n            padding: 5px;\n\n            border-radius: 4px;\n\n            object-fit: contain;\n\n            box-shadow: 0 1px 15px rgba(212,62,195,.75);\n\n        }\n\n\n\n        .auth-frame {\n\n            position: relative;\n\n            z-index: 10;\n\n            width: 100%;\n\n            padding: 1.5px;\n\n            border-radius: 20px;\n\n            background:\n\n                conic-gradient(\n\n                    from var(--ci-login-angle),\n\n                    #262b3b 0deg,\n\n                    var(--ci-login-violet) 50deg,\n\n                    var(--ci-login-cyan) 110deg,\n\n                    #262b3b 170deg,\n\n                    #262b3b 190deg,\n\n                    var(--ci-login-pink) 240deg,\n\n                    var(--ci-login-violet) 290deg,\n\n                    #262b3b 350deg\n\n                );\n\n            animation: ci-login-spin 6s linear infinite;\n\n        }\n\n\n\n        .auth-frame::before {\n\n            content: \"\";\n\n            position: absolute;\n\n            inset: 8px;\n\n            z-index: -1;\n\n            border-radius: inherit;\n\n            background: inherit;\n\n            filter: blur(28px);\n\n            opacity: .55;\n\n        }\n\n\n\n        .auth-card {\n\n            padding: 34px clamp(20px, 6vw, 36px) 30px;\n\n            border-radius: 19px;\n\n            background: rgba(12,14,21,.94);\n\n            backdrop-filter: blur(8px);\n\n        }\n\n\n\n        h1 {\n\n            margin: 0 0 6px;\n\n            font-size: 24px;\n\n            letter-spacing: -.02em;\n\n        }\n\n\n\n        .auth-subtitle {\n\n            margin: 0 0 24px;\n\n            color: var(--ci-login-muted);\n\n            font-size: 14px;\n\n            line-height: 1.55;\n\n        }\n\n\n\n        .alert {\n\n            margin: 0 0 16px;\n\n            padding: 10px 12px;\n\n            border-radius: 10px;\n\n            font-size: 13px;\n\n        }\n\n\n\n        .alert-danger {\n\n            background: rgba(244,63,94,.14);\n\n            color: #fda4af;\n\n        }\n\n\n\n        .alert-success {\n\n            background: rgba(52,211,153,.14);\n\n            color: #a7f3d0;\n\n        }\n\n\n\n        .auth-field {\n\n            margin-bottom: 16px;\n\n        }\n\n\n\n        .auth-row {\n\n            display: flex;\n\n            align-items: baseline;\n\n            justify-content: space-between;\n\n            gap: 12px;\n\n        }\n\n\n\n        .auth-label {\n\n            display: block;\n\n            margin-bottom: 6px;\n\n            color: #d7dbe6;\n\n            font-size: 13px;\n\n            font-weight: 500;\n\n        }\n\n\n\n        .auth-input {\n\n            display: block;\n\n            width: 100%;\n\n            min-height: 44px;\n\n            padding: 10px 14px;\n\n            border: 1px solid var(--ci-login-line);\n\n            border-radius: 10px;\n\n            background: var(--ci-login-field);\n\n            color: var(--ci-login-ink);\n\n            font: inherit;\n\n            font-size: 14px;\n\n        }\n\n\n\n        .auth-input:focus {\n\n            outline: none;\n\n            border-color: var(--ci-login-violet);\n\n            box-shadow: 0 0 0 3px rgba(167,139,250,.25);\n\n        }\n\n\n\n        .auth-submit {\n\n            width: 100%;\n\n            min-height: 48px;\n\n            margin-top: 4px;\n\n            border: 0;\n\n            border-radius: 5px;\n\n            background: linear-gradient(90deg, #7c3aed, #0891b2);\n\n            color: #fff;\n\n            font: inherit;\n\n            font-size: 12pt;\n\n            font-weight: 400;\n\n            cursor: pointer;\n\n            box-shadow: 0 10px 30px -10px rgba(124,58,237,.7);\n\n        }\n\n\n\n        .auth-submit:hover {\n\n            filter: brightness(1.08);\n\n        }\n\n\n\n        .auth-link {\n\n            color: #c4b5fd;\n\n            font-size: 13px;\n\n            text-decoration: none;\n\n        }\n\n\n\n        .auth-link:hover {\n\n            text-decoration: underline;\n\n        }\n\n\n\n        .auth-links {\n\n            display: flex;\n\n            justify-content: space-between;\n\n            gap: 12px;\n\n            margin-top: 22px;\n\n        }\n\n\n\n        .auth-note {\n\n            margin: 24px 0 0;\n\n            color: var(--ci-login-muted);\n\n            font-size: 13px;\n\n            text-align: center;\n\n        }\n\n\n\n        /*\n\n         * Terminal Nexum permanent.\n\n         * Il remplace les quatre notes manuscrites.\n\n         */\n\n        .nexum-dev-terminal {\n\n            position: fixed;\n\n            z-index: 30;\n\n            top: 22px;\n\n            left: 24px;\n\n            width: min(470px, calc(100vw - 48px));\n\n            min-height: 72px;\n\n            pointer-events: none;\n\n\n\n            font-family: \"NexumOCR\", \"Courier New\", monospace;\n\n            font-size: 12px;\n\n            line-height: 1.55;\n\n            letter-spacing: .45px;\n\n            color: rgba(103,201,255,.92);\n\n            text-shadow: 0 0 7px rgba(0,153,204,.34);\n\n        }\n\n\n\n        .nexum-dev-terminal__label {\n\n            margin-bottom: 7px;\n\n            color: rgba(164,223,255,.58);\n\n            font-size: 10px;\n\n            letter-spacing: 1.2px;\n\n            text-transform: uppercase;\n\n        }\n\n\n\n        .nexum-dev-terminal__body {\n\n            white-space: pre-line;\n\n        }\n\n\n\n        .nexum-dev-terminal__cursor {\n\n            display: inline-block;\n\n            margin-left: 3px;\n\n            animation: nexum-terminal-cursor .75s steps(1) infinite;\n\n        }\n\n\n\n        /*\n\n         * Footer toujours en bas.\n\n         */\n\n        .auth-footer {\n\n            position: relative;\n\n            z-index: 25;\n\n            width: 100%;\n\n            padding: 14px 20px 18px;\n\n            color: rgba(162,170,189,.62);\n\n            font-size: 11px;\n\n            line-height: 1.5;\n\n            text-align: center;\n\n        }\n\n\n\n        @keyframes ci-login-spin {\n\n            to {\n\n                --ci-login-angle: 360deg;\n\n            }\n\n        }\n\n\n\n        @keyframes nexum-terminal-cursor {\n\n            0%, 49% { opacity: 1; }\n\n            50%, 100% { opacity: 0; }\n\n        }\n\n\n\n        @media (max-width: 900px) {\n\n            .nexum-dev-terminal {\n\n                top: 16px;\n\n                left: 18px;\n\n                width: calc(100vw - 36px);\n\n                font-size: 10px;\n\n                opacity: .72;\n\n            }\n\n\n\n            .auth-content {\n\n                padding-top: 132px;\n\n            }\n\n        }\n\n\n\n        @media (max-width: 600px) {\n\n            .auth-content {\n\n                padding: 126px 16px 32px;\n\n            }\n\n\n\n            .auth-card {\n\n                padding: 28px 20px 25px;\n\n            }\n\n\n\n            .nexum-dev-terminal {\n\n                max-height: 86px;\n\n                overflow: hidden;\n\n            }\n\n\n\n            .auth-footer {\n\n                padding-inline: 16px;\n\n                font-size: 10px;\n\n            }\n\n        }\n\n\n\n        @media (prefers-reduced-motion: reduce) {\n\n            .auth-frame {\n\n                animation: none;\n\n            }\n\n\n\n            .nexum-dev-terminal__cursor {\n\n                animation: none;\n\n            }\n\n        }\n\n\n\n        /*\n\n * ==================================================\n\n * NEXUM AUDIO PLAYER\n\n * ==================================================\n\n */\n\n\n\n.nexum-audio-player {\n\n\n\nposition: fixed;\n\n\n\nright: 20px;\n\nbottom: 20px;\n\n\n\nz-index: 40;\n\n\n\ndisplay: grid;\n\n\n\ngrid-template-columns:\n\n    38px\n\n    105px\n\n    130px\n\n    60px;\n\n\n\nalign-items: center;\n\n\n\ngap: 10px;\n\n\n\npadding:\n\n    9px\n\n    12px;\n\n\n\n\n\n\n\nbackdrop-filter:\n\n    blur(8px);\n\n\n\nfont-family:\n\n    \"NexumOCR\",\n\n    monospace;\n\n\n\nbox-shadow:\n\n    0 0 20px\n\n    rg\n...[extrait secondaire tronqué]...\n--- END CONTEXT EXCERPT app/Views/auth/form.php ---\n\nREGLE FINALE : ne demande jamais de relire un fichier marque COMPLET ci-dessus. Produis maintenant files[]/database ou indique uniquement une dependance concrete absente.\n\n\n=== CONTEXTE FINAL VERROUILLE evolution — SOURCE DE VERITE ===\nv2.4.7 : tout chemin exact explicite et resolu est promu OBLIGATOIRE, meme s'il avait deja ete decouvert. Les blocs PINNED sont injectes entiers et jamais tronques.\nCes fichiers viennent d'etre relus sur le disque juste avant la generation du diff. Les marqueurs COMPLET signifient que le contenu entre BEGIN/END est integral.\n\nPLAN D'EXPLORATION EXECUTE PAR evolution :\n- path: app/Services/DevelopmentCodeExplorerService.php => 1 nouveau(x) fichier(s), cible exacte promue OBLIGATOIRE\n- PIN path obligatoire -> app/Services/DevelopmentCodeExplorerService.php\n\n=== CONTEXTE OBLIGATOIRE — CHEMINS EXACTS AUTORISES ===\n\n--- BEGIN PINNED app/Services/DevelopmentCodeExplorerService.php | 83944 octets | sha256=058ae1dfc15a62a08526b853bd549d24d92eeaa9dfca40ae35e99a0db1809559 | COMPLET ---\n<?php\nnamespace App\\Services;\n\n/**\n * Explorateur de code en lecture seule pour le Development Engine.\n *\n * Objectif : partir d'une demande fonctionnelle et reconstruire autant que\n * possible la chaîne Route -> Controller -> méthode -> View -> assets.\n */\nclass DevelopmentCodeExplorerService\n{\n    private const MAX_FILES = 10;\n    private const MAX_SNIPPET = 30000;\n    private const MAX_TARGET_CONTENT = 70000;\n    private const MAX_PINNED_FILE = 220000;\n    private const MAX_PINNED_CONTEXT = 260000;\n    private const MAX_SECONDARY_SNIPPET = 14000;\n    private const MAX_CONTEXT = 180000;\n    private const MAX_FILE_SIZE = 900000;\n\n    private array $roots = ['app', 'public/assets'];\n    private array $extensions = ['php', 'js', 'css', 'json', 'md'];\n    private string $projectRoot;\n\n    public function __construct(?string $projectId=null)\n    {\n        $project=(new DevelopmentProjectManagerService())->resolve($projectId?:'system');\n        $this->projectRoot=rtrim((string)$project['root'],'\\\\/').DIRECTORY_SEPARATOR;\n        if(($project['type']??'')==='codeigniter3') $this->roots=['application','assets'];\n    }\n\n    public function explore(string $request, ?string $sourceUri = null): string\n    {\n        $terms = $this->terms($request);\n        $sourceUri = trim((string) $sourceUri, '/');\n        if (!$terms && $sourceUri === '') {\n            return '';\n        }\n\n        $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n        $selected = [];\n        $contents = [];\n        $chain = [];\n        $forcedSnippets = [];\n\n        // 1. Routes : toujours examinées en premier, car elles permettent de\n        // remonter proprement vers le contrôleur même si son nom n'apparaît pas\n        // dans la demande utilisateur.\n        $routesPath = 'app/Config/Routes.php';\n        $routesContent = $this->readRelative($root, $routesPath);\n        if ($routesContent !== null) {\n            // La page depuis laquelle le Development Engine est appelé est un\n            // ancrage fiable, même si la demande est vague (ex. \"trouve tout seul\").\n            // Priorité à la cible exprimée dans la demande. L'URI source n'est\n            // qu'un filet de sécurité pour les demandes vagues portant sur la page courante.\n            $routeMatches = $this->findMatchingRoutes($routesContent, $terms);\n            if(!$routeMatches && $sourceUri !== '') {\n                $routeMatches = $this->findRoutesByUri($routesContent, $sourceUri);\n            }\n\n            // Dédupliquer les routes retenues.\n            foreach ([] as $candidate) {\n                $key = strtolower($candidate['verb'].'|'.$candidate['uri'].'|'.$candidate['controller'].'|'.$candidate['method']);\n                $exists = false;\n                foreach ($routeMatches as $existing) {\n                    $existingKey = strtolower($existing['verb'].'|'.$existing['uri'].'|'.$existing['controller'].'|'.$existing['method']);\n                    if ($existingKey === $key) { $exists = true; break; }\n                }\n                if (!$exists) { $routeMatches[] = $candidate; }\n            }\n\n            if ($routeMatches) {\n                $this->addFile($selected, $contents, $routesPath, $routesContent);\n\n                foreach ($routeMatches as $route) {\n                    $chain[] = sprintf(\n                        'Route %s %s -> %s::%s()',\n                        strtoupper($route['verb']),\n                        $route['uri'],\n                        $route['controller'],\n                        $route['method']\n                    );\n\n                    // 2. Contrôleur réellement pointé par la route.\n                    $controllerPath = $this->resolveControllerPath($root, $route['controller']);\n                    $controllerContent = $this->readRelative($root, $controllerPath);\n                    if ($controllerContent === null) {\n                        continue;\n                    }\n\n                    $this->addFile($selected, $contents, $controllerPath, $controllerContent);\n\n                    // 3. Méthode ciblée : extraire en priorité son bloc pour\n                    // identifier les vues sans dépendre du score de recherche.\n                    $methodBlock = $this->extractMethodBlock($controllerContent, $route['method']);\n                    // Pour les propositions, fournir le contrôleur réel (pas seulement\n                    // le dernier bloc de méthode rencontré). Plusieurs routes peuvent pointer\n                    // vers le même contrôleur et écrasaient auparavant index() avec finalize().\n                    $forcedSnippets[$controllerPath] = $controllerContent;\n                    $views = $this->extractViews($methodBlock ?: $controllerContent);\n\n                    foreach ($views as $view) {\n                        // Les vues dynamiques ne sont pas résolues ici : on ne\n                        // doit jamais inventer leur chemin.\n                        if (str_contains($view, '$') || str_contains($view, '{')) {\n                            continue;\n                        }\n\n                        $viewPath = 'app/Views/' . trim($view, '/') . '.php';\n                        $viewContent = $this->readRelative($root, $viewPath);\n                        if ($viewContent === null) {\n                            continue;\n                        }\n\n                        $chain[] = sprintf('%s::%s() -> vue %s', $route['controller'], $route['method'], $viewPath);\n                        $this->addFile($selected, $contents, $viewPath, $viewContent);\n                        $forcedSnippets[$viewPath] = $viewContent;\n\n                        // 4. Assets explicitement référencés par la vue.\n                        foreach ($this->extractAssetPaths($viewContent) as $assetPath) {\n                            $assetContent = $this->readRelative($root, $assetPath);\n                            if ($assetContent !== null) {\n                                $chain[] = sprintf('Vue %s -> asset %s', $viewPath, $assetPath);\n                                $this->addFile($selected, $contents, $assetPath, $assetContent);\n                            }\n                        }\n\n                        // 5. Classes CSS spécifiques de la vue : chercher leurs\n                        // définitions dans les feuilles locales connues.\n                        foreach ($this->findCssForView($root, $viewContent) as $cssPath => $cssContent) {\n                            $chain[] = sprintf('Styles spécifiques détectés -> %s', $cssPath);\n                            $this->addFile($selected, $contents, $cssPath, $cssContent);\n                        }\n                    }\n                }\n            }\n        }\n\n        // 6. Exploration autonome multi-étapes : une demande d'interface contient\n        // souvent un libellé visible (ex. \"Administration\") sans URL ni nom de\n        // contrôleur. On recherche d'abord ces expressions littérales dans le code\n        // réel, puis on suit les références de vues/layouts/assets trouvées.\n        foreach ($this->literalMatches($root, $request) as $path => $content) {\n            if (count($selected) >= self::MAX_FILES) {\n                break;\n            }\n            $this->addFile($selected, $contents, $path, $content);\n            $forcedSnippets[$path] = $content;\n            $chain[] = 'Texte/interface localisé automatiquement -> ' . $path;\n            $this->followFileReferences($root, $path, $content, $selected, $contents, $forcedSnippets, $chain);\n        }\n\n        // 7. Recherche textuelle générale en complément de la chaîne résolue.\n        // Elle sert pour les services, modèles, JS ou CSS non explicitement liés.\n        $scores = $this->scoreProjectFiles($root, $terms);\n        foreach (array_keys($scores) as $path) {\n            if (count($selected) >= self::MAX_FILES) {\n                break;\n            }\n            if (isset($contents[$path])) {\n                continue;\n            }\n            $content = $this->readRelative($root, $path);\n            if ($content !== null) {\n                $this->addFile($selected, $contents, $path, $content);\n            }\n        }\n\n        if (!$selected) {\n            return '';\n        }\n\n        $out = \"\\n\\n=== CODE EXPLORER evolution — LECTURE SEULE ===\\n\";\n        $out .= \"Les informations suivantes proviennent du code réellement présent sur le serveur. \"\n            . \"Utilise-les comme source de vérité. Ne prétends jamais avoir lu un fichier absent de cette section.\\n\";\n\n        if ($chain) {\n            $out .= \"\\nCHAÎNE TECHNIQUE RÉSOLUE AUTOMATIQUEMENT :\\n\";\n            foreach (array_values(array_unique($chain)) as $step) {\n                $out .= \"- {$step}\\n\";\n            }\n        }\n\n        $out .= \"\\nFICHIERS LUS AUTOMATIQUEMENT :\\n\";\n        foreach (array_slice($selected, 0, self::MAX_FILES) as $path) {\n            $content = $contents[$path] ?? '';\n            $out .= \"\\n--- {$path} ---\\n\";\n            if (isset($forcedSnippets[$path])) {\n                $out .= $this->targetContent($forcedSnippets[$path]) . \"\\n\";\n            } else {\n                $out .= $this->snippet($content, $terms) . \"\\n\";\n            }\n            if (strlen($out) >= self::MAX_CONTEXT) {\n                break;\n            }\n        }\n\n        $out .= \"\\nRÈGLE D'UTILISATION : si la chaîne Route -> Controller -> méthode -> View est résolue ci-dessus, \"\n            . \"ne demande pas à l'administrateur de fournir manuellement ces fichiers. Analyse directement les contenus fournis. Les fichiers marqués comme cibles déterministes sont transmis intégralement jusqu'à 70 Ko ; ne les considère pas comme tronqués s'ils ne portent pas le marqueur d'omission evolution.\\n\";\n\n        return AiTextEncodingService::slice($out, 0, self::MAX_CONTEXT);\n    }\n\n    /**\n     * Pré-résolution sémantique des demandes d'interface.\n     *\n     * Elle ne donne aucun droit d'écriture : elle transforme uniquement une\n     * intention UI (apparence, boutons, thème, réglages, layout...) en cibles\n     * techniques vérifiées sur le disque. Le but est d'éviter d'attendre que\n     * le modèle redemande des fichiers évidents au premier cycle.\n     */\n    public function inferSemanticDependencies(string $request): array\n    {\n        $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n        $lower = $this->ascii(mb_strtolower($request));\n        $isUi = preg_match('/\\b(apparence|bouton|buttons?|theme|couleur|style|css|interface|layout|sidebar|menu|formulaire|bordure|radius|arrondi|admin)\\b/', $lower) === 1;\n        $isSettings = preg_match('/\\b(reglage|reglages|settings?|parametre|parametres|apparence|theme)\\b/', $lower) === 1;\n        $isDevelopmentUi = preg_match('/\\b(evolution ia|evolution-ia|development engine|development|moteur de developpement|conversation technique|code explorer|controleur de streaming|controller de streaming|streaming)\\b/', $lower) === 1;\n        if (!$isUi && !$isSettings && !$isDevelopmentUi) return [];\n\n        $hints = [];\n        $seen = [];\n        $add = static function(array $hint) use (&$hints, &$seen): void {\n            $key = strtolower(($hint['type'] ?? '').'|'.($hint['value'] ?? ''));\n            if ($key === '|' || isset($seen[$key])) return;\n            $seen[$key] = true;\n            $hints[] = $hint;\n        };\n        $addPath = function(string $path) use ($root, $add): void {\n            if ($this->readRelative($root, $path) !== null) $add(['type'=>'path','value'=>$path]);\n        };\n\n        // Une demande explicitement limitée à l'administration doit toujours\n        // connaître le layout admin et ses assets locaux avant finalisation.\n        if (str_contains($lower, 'admin')) {\n            $addPath('app/Views/layouts/admin.php');\n            $layout = $this->readRelative($root, 'app/Views/layouts/admin.php');\n            if ($layout !== null) {\n                foreach ($this->extractAssetPaths($layout) as $assetPath) $addPath($assetPath);\n            }\n        }\n\n        // v2.3.5 — l'interface propre à Évolution IA est une cible de lecture\n        // de premier ordre. Lorsqu'une demande concerne le Development Engine,\n        // on expose directement sa vue principale, son contrôleur, son layout et\n        // ses scripts locaux avant toute finalisation.\n        if ($isDevelopmentUi) {\n            foreach ([\n                'app/Views/admin/development/index.php',\n                'app/Controllers/Admin/Development.php',\n                'app/Views/layouts/admin.php',\n                'public/assets/js/development-engine.js',\n                'public/assets/js/development-voice.js',\n            ] as $path) $addPath($path);\n            $routes = $this->readRelative($root, 'app/Config/Routes.php');\n            if ($routes !== null && $this->findRoutesByUri($routes, 'admin/evolution-ia')) {\n                $add(['type'=>'route','value'=>'admin/evolution-ia']);\n            }\n        }\n\n        // Apparence/réglages : suivre la vraie route si elle existe, puis le\n        // système de settings et ses migrations. Toutes les cibles sont ensuite\n        // résolues par les mécanismes path/symbol/table existants.\n        if ($isSettings) {\n            $routes = $this->readRelative($root, 'app/Config/Routes.php');\n            if ($routes !== null) {\n                foreach (['admin/reglages', 'reglages'] as $uri) {\n                    if ($this->findRoutesByUri($routes, $uri)) { $add(['type'=>'route','value'=>$uri]); break; }\n                }\n            }\n            foreach (['app/Controllers/Admin/Settings.php','app/Views/admin/settings.php'] as $path) $addPath($path);\n            foreach (['SettingService','SettingModel'] as $symbol) {\n                if ($this->resolveSymbolPaths($root, $symbol)) $add(['type'=>'symbol','value'=>$symbol]);\n            }\n            if ($this->resolveTablePaths($root, 'settings')) $add(['type'=>'table','value'=>'settings']);\n        }\n\n        // Si l'UI admin est ciblée mais le mot réglages n'est pas écrit, les\n        // styles locaux du layout restent nécessaires pour décider où appliquer\n        // les variables sans toucher à l'espace membre.\n        if ($isUi && str_contains($lower, 'admin')) {\n            foreach (['public/assets/css/evolution.css','public/assets/css/evolution-theme.css'] as $path) $addPath($path);\n        }\n\n        return array_slice($hints, 0, 16);\n    }\n\n    /**\n     * Convertit une intention de poursuite exprimée en langage naturel\n     * (ex. \"explorer la partie publique du diaporama\") en dépendances\n     * techniques réelles. Aucune cible n'est inventée : seules les routes,\n     * symboles et fichiers effectivement présents sur disque sont retournés.\n     */\n    public function inferDeferredDependencies(string $request, string $summary, array $alreadySeen = []): array\n    {\n        $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n        $text = trim($request.' '.$summary);\n        if ($text === '') return [];\n\n        $hints = [];\n        $seenKeys = [];\n        foreach ($alreadySeen as $hint) {\n            $seenKeys[is_array($hint) ? json_encode($hint) : 's:'.(string)$hint] = true;\n        }\n        $add = static function(array $hint) use (&$hints, &$seenKeys): void {\n            $key = json_encode($hint);\n            if (isset($seenKeys[$key])) return;\n            $seenKeys[$key] = true;\n            $hints[] = $hint;\n        };\n\n        // v2.3.1 : si le Finalizer écrit déjà un plan structuré dans sa prose,\n        // l'exécuter tel quel. Cela couvre notamment \"symbol: Library\",\n        // \"schema: media\", \"migration: media\" et \"search: csrf_hash\".\n        if (preg_match_all('/^\\s*(?:[-*]\\s*)?(path|symbol|method|route|table|schema|migration|backup|install|rollback|search|service)\\s*:\\s*(.+?)\\s*$/mi', $summary, $structured, PREG_SET_ORDER)) {\n            foreach ($structured as $row) {\n                $type = strtolower(trim((string) $row[1]));\n                $value = trim((string) $row[2], \" \\t\\n\\r\\0\\x0B`\");\n                if ($type === 'path') $value = trim(str_replace('\\\\\\\\', '/', $value), '/');\n                if ($value === '') continue;\n                if ($type === 'path' && $this->readRelative($root, $value) === null) continue;\n                $add(['type'=>$type,'value'=>$value]);\n            }\n        }\n\n        // v2.3.2 — normalisation du langage naturel en plan d'exploration.\n        // Le Finalizer n'est pas obligé d'émettre les préfixes path:/schema:/...\n        // pour que evolution exécute une demande explicite. Les formulations restent\n        // volontairement bornées et déterministes : chemins existants, tables\n        // nommées et termes de recherche littéraux réellement cités.\n        $natural = $this->normalizeNaturalExplorationPlan($summary);\n        foreach ($natural as $hint) $add($hint);\n\n        // v2.2.8 : une formulation tardive comme « le Code Explorer doit lire »\n        // reste une dépendance exécutable, jamais une tâche pour l'administrateur.\n        // 1) Tout chemin explicite réellement présent reste prioritaire.\n        if (preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\.php|\\.js|\\.css|\\.json|\\.md)?~i', $summary, $m)) {\n            foreach ($m[0] as $path) {\n                $path = trim(str_replace('\\\\', '/', $path), '/');\n                if ($this->readRelative($root, $path) !== null) $add(['type'=>'path','value'=>$path]);\n            }\n        }\n\n        // 2) Pour une demande \"publique / membre / familiale\", résoudre les\n        // routes non-admin correspondant au sujet réel de la demande.\n        $lower = mb_strtolower($summary);\n        $publicIntent = preg_match('/\\b(public(?:que)?|membre|familial(?:e)?|front(?:end)?|côté membre|cote membre)\\b/u', $lower) === 1;\n        $routesContent = $this->readRelative($root, 'app/Config/Routes.php');\n        if ($routesContent !== null) {\n            $terms = $this->terms($text);\n            $routes = $this->routeDefinitions($routesContent);\n            $ranked = [];\n            foreach ($routes as $route) {\n                $uri = trim((string)($route['uri'] ?? ''), '/');\n                $controller = (string)($route['controller'] ?? '');\n                if ($publicIntent && (str_starts_with($uri, 'admin/') || str_contains($controller, '\\\\Admin\\\\') || $uri === 'admin')) continue;\n                $haystack = $this->ascii(mb_strtolower($uri.' '.$controller.'::'.(string)($route['method'] ?? '')));\n                $score = 0;\n                foreach ($terms as $term) {\n                    $needle = $this->ascii($term);\n                    if ($needle !== '' && str_contains($haystack, $needle)) $score += 5;\n                }\n                if ($score > 0) { $route['score'] = $score; $ranked[] = $route; }\n            }\n            usort($ranked, static fn(array $a,array $b): int => ($b['score']??0) <=> ($a['score']??0));\n            foreach (array_slice($ranked,0,6) as $route) {\n                $uri = trim((string)($route['uri'] ?? ''), '/');\n                if ($uri !== '') $add(['type'=>'route','value'=>$uri]);\n            }\n        }\n\n        // 3) Classes techniques explicitement nommées dans l'explication.\n        if (preg_match_all('/\\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\\b/', $summary, $m)) {\n            foreach ($m[0] as $symbol) {\n                if ($this->resolveSymbolPaths($root, $symbol)) $add(['type'=>'symbol','value'=>$symbol]);\n            }\n        }\n\n        // 4) Si l'intention est publique et aucune route n'a été trouvée par le\n        // scoring, chercher un contrôleur non-admin dont le nom correspond à un\n        // terme fonctionnel présent dans la demande (ex. Slideshow/diaporama).\n        if ($publicIntent && !$hints) {\n            foreach ($this->terms($text) as $term) {\n                if (mb_strlen($term) < 5) continue;\n                foreach ($this->resolveSymbolPaths($root, ucfirst($term).'Controller') as $path) {\n                    $add(['type'=>'path','value'=>$path]);\n                    if (count($hints) >= 4) break 2;\n                }\n            }\n        }\n\n        // v2.2.9 — une demande tardive de recherche textuelle devient une\n        // opération du Code Explorer. On ne transforme que les termes réellement\n        // cités dans summary afin d'éviter les recherches génériques inventées.\n        foreach ($this->extractRequestedSearchTerms($summary) as $term) {\n            $add(['type'=>'search','value'=>$term]);\n        }\n        if (preg_match('/\b(service|services)\b.{0,80}\b(fichier|fichiers|upload|uploads|media|stockage|storage)\b/iu', $summary)\n            || preg_match('/\b(fichier|fichiers|upload|uploads|media|stockage|storage)\b.{0,80}\b(service|services)\b/iu', $summary)) {\n            $add(['type'=>'service','value'=>'file-management']);\n        }\n\n        return array_slice($hints, 0, 12);\n    }\n\n    /**\n     * Résout les besoins structurels exprimés par l'IA : schéma de tables,\n     * migrations existantes et cycle backup/install/rollback. Ces dépendances\n     * sont converties en cibles déterministes ; aucune écriture n'est réalisée.\n     */\n    public function inferStructuralDependencies(string $request,string $summary,array $alreadySeen=[]): array\n    {\n        $root=rtrim($this->projectRoot,DIRECTORY_SEPARATOR);\n        $text=trim($request.' '.$summary);\n        if($text==='') return [];\n        $lower=$this->ascii(mb_strtolower($text));\n        $hints=[];$seen=[];\n        foreach($alreadySeen as $hint){\n            if(is_array($hint)) $seen[strtolower((string)($hint['type']??'').'|'.(string)($hint['value']??''))]=true;\n        }\n        $add=static function(string $type,string $value) use (&$hints,&$seen): void {\n            $value=trim($value); if($value==='') return;\n            $key=strtolower($type.'|'.$value); if(isset($seen[$key])) return;\n            $seen[$key]=true; $hints[]=['type'=>$type,'value'=>$value];\n        };\n\n        // Fichiers/classes explicitement nommés.\n        if(preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\\\.php|\\\\.js|\\\\.css|\\\\.json|\\\\.md)?~i',$summary,$m)){\n            foreach($m[0] as $path){$path=trim(str_replace('\\\\\\\\','/',$path),'/');if($this->readRelative($root,$path)!==null)$add('path',$path);}\n        }\n        if(preg_match_all('/\\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\\b/',$summary,$m)){\n            foreach($m[0] as $symbol){\n                if(!$this->resolveSymbolPaths($root,$symbol)) continue;\n                $add('symbol',$symbol);\n                if(str_ends_with($symbol,'Model')){\n                    $table=$this->resolveModelTable($root,$symbol);\n                    if($table!==null){\n                        $add('schema',$table);\n                        $add('migration',$table);\n                    }\n                }\n            }\n        }\n\n        // Tables explicitement nommées : dev_xxx, noms entre backticks, ou\n        // mentions proches de \"table / colonnes / schéma / migration\".\n        $tables=[];\n        if(preg_match_all('/`([a-z][a-z0-9_]{2,})`/i',$summary,$m)) $tables=array_merge($tables,$m[1]);\n        if(preg_match_all('/\\\\b(dev_[a-z0-9_]+|[a-z][a-z0-9_]*_[a-z0-9_]+)\\\\b/i',$summary,$m)) $tables=array_merge($tables,$m[1]);\n        // Tables simples comme `media` : uniquement dans une formulation\n        // structurelle explicite pour éviter les faux positifs.\n        if(preg_match_all('/\\btable\\s+[`\"\\']?([a-z][a-z0-9_]{2,})[`\"\\']?/ui',$text,$m)) $tables=array_merge($tables,$m[1]);\n        if(preg_match_all('/\\b(?:table|schema|schéma|colonnes?|columns?|index|indexes|migration(?:s)?)(?:\\s+(?:sql|réel|reel|exacte?|existantes?)){0,3}\\s+(?:de|du|pour|sur|concernant)\\s+[`\"\\']?([a-z][a-z0-9_]{2,})[`\"\\']?/ui',$text,$m)) $tables=array_merge($tables,$m[1]);\n        $tables=array_values(array_unique(array_map('strtolower',$tables)));\n        $wantsSchema=preg_match('/\\\\b(schema|schéma|structure|colonnes?|columns?|index|indexes)\\\\b/ui',$text)===1;\n        $wantsMigration=preg_match('/\\\\b(migration|migrations|forge)\\\\b/ui',$text)===1;\n        foreach($tables as $table){\n            // v2.3.5 — les candidats issus du texte libre (notamment les mots\n            // avec underscore) doivent correspondre à une table réellement\n            // connectée avant de devenir une dépendance BDD.\n            if(!$this->isExistingDatabaseTable($table)) continue;\n            if($wantsSchema) $add('schema',$table);\n            else $add('table',$table);\n            if($wantsMigration) $add('migration',$table);\n        }\n\n        // Cycle d'installation / sauvegarde / rollback.\n        if(preg_match('/\\\\b(backup|backups|sauvegarde|sauvegardes)\\\\b/ui',$text)) $add('backup','development');\n        if(preg_match('/\\\\b(install|installation|installer|migration runner|migrations)\\\\b/ui',$text)) $add('install','development');\n        if(preg_match('/\\\\b(rollback|restore|restauration|restaurer)\\\\b/ui',$text)) $add('rollback','development');\n\n        // La demande du \"début complet\" d'un service signifie qu'il faut le\n        // relire intégralement : symbol/path le garantit dans le contexte pinned.\n        if(str_contains($lower,'developmentproposalservice') && $this->readRelative($root,'app/Services/DevelopmentProposalService.php')!==null){\n            $add('path','app/Services/DevelopmentProposalService.php');\n        }\n\n        return array_slice($hints,0,16);\n    }\n\n    /**\n     * Valide uniquement les noms de tables déduits depuis du texte libre.\n     * Les dépendances structurées explicites restent conservées par leur\n     * protocole ; cette vérification évite surtout les faux positifs comme\n     * needs_context ou migration_path.\n     */\n    private function isExistingDatabaseTable(string $table): bool\n    {\n        static $available=null;\n        $table=preg_replace('/[^A-Za-z0-9_]/','',$table);\n        if($table==='') return false;\n        if($available===null){\n            $available=[];\n            try{\n                foreach(db_connect()->listTables() as $name)\n                    $available[strtolower((string)$name)]=true;\n            }catch(\\Throwable){\n                return false;\n            }\n        }\n        return isset($available[strtolower($table)]);\n    }\n\n    /**\n     * Expansion ciblée demandée par le moteur de proposition.\n     * Les indices viennent d'une première analyse IA, mais toutes les lectures\n     * sont résolues et vérifiées localement avant d'être injectées.\n     */\n    public function exploreDependencies(string $request, array $hints): string\n    {\n        $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n        $selected = [];\n        $contents = [];\n        $chain = [];\n        $normalized = $this->normalizeDependencyHints($hints);\n        if (!$normalized) return '';\n\n        // Résolution déterministe et prioritaire : les dépendances explicites sont\n        // épinglées avant toute recherche textuelle. Une phrase IA n'est jamais\n        // transformée aveuglément en dizaines de termes génériques.\n        foreach ($normalized as $hint) {\n            $type = $hint['type'];\n            $value = $hint['value'];\n            if ($type === 'path') {\n                $this->pinPath($root, $value, $selected, $contents, $chain, 'Chemin exact');\n            } elseif ($type === 'symbol') {\n                foreach ($this->resolveSymbolPaths($root, $value) as $path) {\n                    $this->pinPath($root, $path, $selected, $contents, $chain, 'Symbole '.$value);\n                }\n            } elseif ($type === 'method') {\n                foreach ($this->resolveMethodPaths($root, $value) as $path) {\n                    $this->pinPath($root, $path, $selected, $contents, $chain, 'Méthode '.$value);\n                }\n            } elseif ($type === 'route') {\n                $routes = $this->readRelative($root, 'app/Config/Routes.php');\n                if ($routes !== null) {\n                    $this->pinPath($root, 'app/Config/Routes.php', $selected, $contents, $chain, 'Route '.$value);\n                    foreach ($this->findRoutesByUri($routes, trim($value, '/')) as $route) {\n                        $controllerPath = $this->resolveControllerPath($root, $route['controller']);\n                        $this->pinPath($root, $controllerPath, $selected, $contents, $chain, 'Contrôleur de route '.$value);\n                        $controller = $contents[$controllerPath] ?? '';\n                        $block = $this->extractMethodBlock($controller, $route['method']);\n                        foreach ($this->extractViews($block ?: $controller) as $view) {\n                            if (!str_contains($view, '$') && !str_contains($view, '{')) {\n                                $this->pinPath($root, 'app/Views/'.trim($view, '/').'.php', $selected, $contents, $chain, 'Vue de route '.$value);\n                            }\n                        }\n                    }\n                }\n            } elseif (in_array($type,['table','schema','migration'],true)) {\n                foreach ($this->resolveTablePaths($root, $value) as $path) {\n                    $this->pinPath($root, $path, $selected, $contents, $chain, strtoupper($type).' '.$value);\n                }\n            } elseif (in_array($type,['backup','install','rollback'],true)) {\n                foreach ($this->resolveLifecyclePaths($root,$type,$value) as $path) {\n                    $this->pinPath($root,$path,$selected,$contents,$chain,strtoupper($type).' '.$value);\n                }\n            } elseif ($type === 'search') {\n                foreach ($this->resolveSearchPaths($root,$value) as $path) {\n                    $this->pinPath($root,$path,$selected,$contents,$chain,'Recherche exacte '.$value);\n                }\n            } elseif ($type === 'service') {\n                foreach ($this->resolveServicePaths($root,$value) as $path) {\n                    $this->pinPath($root,$path,$selected,$contents,$chain,'Service '.$value);\n                }\n            }\n            if (count($selected) >= 14) break;\n        }\n\n        if (!$selected) return '';\n        $out = \"\\n\\n=== EXPANSION STRUCTURÉE DES DÉPENDANCES — LECTURE SEULE ===\\n\";\n        $out .= \"Les fichiers ci-dessous ont été résolus par chemin, symbole, méthode, route ou table réels. Ils sont prioritaires.\\n\";\n        foreach (array_values(array_unique($chain)) as $step) $out .= '- '.$step.\"\\n\";\n        $out .= \"\\nFICHIERS DE DÉPENDANCES PRIORITAIRES :\\n\";\n        foreach ($selected as $path) {\n            $out .= \"\\n--- {$path} ---\\n\".$this->targetContent($contents[$path] ?? '').\"\\n\";\n            if (strlen($out) >= 260000) break;\n        }\n        $out .= \"\\nRÈGLE : ces dépendances sont déjà lues depuis le disque réel. Ne demande pas une nouvelle exploration d'un élément présent ci-dessus.\\n\";\n        return AiTextEncodingService::slice($out, 0, 260000);\n    }\n\n    /**\n     * Contexte final verrouillé pour la génération du diff.\n     * Contrairement au contexte exploratoire, les dépendances explicites sont\n     * relues juste avant l'appel final et ne passent ni par snippet() ni par la\n     * limite globale de 180 Ko. Chaque fichier est accompagné de sa taille et\n     * d'un marqueur COMPLET vérifiable.\n     */\n    public function explorePinnedDependencies(string $request, array $hints): string\n    {\n        $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n        $selected = [];\n        $contents = [];\n        $chain = [];\n        $executed = [];\n        $normalized = $this->normalizeDependencyHints($hints);\n        if (!$normalized) return '';\n\n        // v2.4.5 — les chemins exacts demandés/autorisés sont obligatoires.\n        // Ils sont lus et placés AVANT toutes les dépendances secondaires.\n        $mandatoryHints = [];\n        $secondaryHints = [];\n        foreach ($normalized as $hint) {\n            if (($hint['type'] ?? '') === 'path') $mandatoryHints[] = $hint;\n            else $secondaryHints[] = $hint;\n        }\n\n        $mandatoryPaths = [];\n        foreach ($mandatoryHints as $hint) {\n            $value = (string)($hint['value'] ?? '');\n            $before = count($selected);\n            $this->pinPath($root, $value, $selected, $contents, $chain, 'PIN path obligatoire');\n\n            // v2.4.7 — un path explicite est obligatoire même si le fichier\n            // avait déjà été découvert par une dépendance précédente. La\n            // v2.4.5 ne promouvait que le dernier fichier *nouvellement*\n            // ajouté à $selected, ce qui rendait l'obligation dépendante de\n            // l'ordre d'exploration.\n            $exactPath = trim(str_replace('\\\\', '/', $value), '/');\n            if ($exactPath !== '' && array_key_exists($exactPath, $contents)) {\n                $mandatoryPaths[$exactPath] = true;\n            }\n\n            $executed[] = 'path: '.$value.' => '.max(0, count($selected) - $before).\n                ' nouveau(x) fichier(s), '.(isset($mandatoryPaths[$exactPath]) ? 'cible exacte promue OBLIGATOIRE' : 'aucun fichier exact résolu');\n        }\n\n        foreach ($secondaryHints as $hint) {\n            $type=$hint['type']; $value=$hint['value']; $before=count($selected);\n            if ($type==='symbol') {\n                foreach($this->resolveSymbolPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN symbol '.$value);\n            } elseif ($type==='method') {\n                foreach($this->resolveMethodPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN method '.$value);\n            } elseif ($type==='route') {\n                $routes=$this->readRelative($root,'app/Config/Routes.php');\n                if($routes!==null){\n                    $this->pinPath($root,'app/Config/Routes.php',$selected,$contents,$chain,'PIN route '.$value);\n                    foreach($this->findRoutesByUri($routes,trim($value,'/')) as $route){\n                        $controllerPath=$this->resolveControllerPath($root,$route['controller']);\n                        $this->pinPath($root,$controllerPath,$selected,$contents,$chain,'PIN controller route '.$value);\n                        $controller=$contents[$controllerPath]??'';\n                        $block=$this->extractMethodBlock($controller,$route['method']);\n                        foreach($this->extractViews($block?:$controller) as $view){\n                            if(!str_contains($view,'$')&&!str_contains($view,'{')) $this->pinPath($root,'app/Views/'.trim($view,'/').'.php',$selected,$contents,$chain,'PIN view route '.$value);\n                        }\n                    }\n                }\n            } elseif (in_array($type,['table','schema','migration'],true)) {\n                foreach($this->resolveTablePaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN '.$type.' '.$value);\n            } elseif (in_array($type,['backup','install','rollback'],true)) {\n                foreach($this->resolveLifecyclePaths($root,$type,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN '.$type.' '.$value);\n            } elseif ($type==='search') {\n                foreach($this->resolveSearchPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN search '.$value);\n            } elseif ($type==='service') {\n                foreach($this->resolveServicePaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN service '.$value);\n            }\n            $executed[]=$type.': '.$value.' => '.max(0,count($selected)-$before).' nouveau(x) fichier(s) épinglé(s)';\n        }\n        if(!$selected) return '';\n\n        $out=\"\\n\\n=== CONTEXTE FINAL VERROUILLE evolution — SOURCE DE VERITE ===\\n\";\n        $out.=\"v2.4.7 : tout chemin exact explicite et resolu est promu OBLIGATOIRE, meme s'il avait deja ete decouvert. Les blocs PINNED sont injectes entiers et jamais tronques.\\n\";\n        $out.=\"Ces fichiers viennent d'etre relus sur le disque juste avant la generation du diff. Les marqueurs COMPLET signifient que le contenu entre BEGIN/END est integral.\\n\";\n        $out.=\"\\nPLAN D'EXPLORATION EXECUTE PAR evolution :\\n\";\n        foreach(array_values(array_unique($executed)) as $step) $out.='- '.$step.\"\\n\";\n        foreach(array_values(array_unique($chain)) as $step) $out.='- '.$step.\"\\n\";\n\n        $footer=\"\\nREGLE FINALE : ne demande jamais de relire un fichier marque COMPLET ci-dessus. Produis maintenant files[]/database ou indique uniquement une dependance concrete absente.\\n\";\n        $omitted=[];\n\n        // 1) Contexte obligatoire : tous les path explicites doivent tenir EN ENTIER.\n        if ($mandatoryPaths) {\n            $out.=\"\\n=== CONTEXTE OBLIGATOIRE — CHEMINS EXACTS AUTORISES ===\\n\";\n        }\n        foreach(array_keys($mandatoryPaths) as $path){\n            $content=$contents[$path]??''; $size=strlen($content);\n            if($size>self::MAX_PINNED_FILE){\n                throw new \\RuntimeException(\n                    'Code Explorer : fichier obligatoire trop volumineux pour la garantie COMPLET : '.\n                    $path.' ('.$size.' octets, limite '.self::MAX_PINNED_FILE.').'\n                );\n            }\n            $hash=hash('sha256',$content);\n            $block=\"\\n--- BEGIN PINNED {$path} | {$size} octets | sha256={$hash} | COMPLET ---\\n\".$content.\"\\n--- END PINNED {$path} | COMPLET ---\\n\";\n            $needed=strlen($out)+strlen($block)+strlen($footer);\n            if($needed>self::MAX_PINNED_CONTEXT){\n                throw new \\RuntimeException(\n                    'Code Explorer : budget de contexte insuffisant pour injecter EN ENTIER les fichiers obligatoires. '.\n                    'Bloc refuse : '.$path.' ('.$size.' octets). Budget '.self::MAX_PINNED_CONTEXT.\n                    ' octets, besoin minimal '.$needed.' octets. Aucun fichier obligatoire n\\'a ete tronque.'\n                );\n            }\n            $out.=$block;\n        }\n\n        // 2) Contexte secondaire : uniquement des blocs atomiques complets.\n        // On ne coupe plus jamais un bloc avec substr().\n        $secondaryPaths=array_values(array_filter($selected, static fn($path) => !isset($mandatoryPaths[$path])));\n        if($secondaryPaths){\n            $section=\"\\n=== CONTEXTE SECONDAIRE — DEPENDANCES RESOLUES ===\\n\";\n            if(strlen($out)+strlen($section)+strlen($footer)<=self::MAX_PINNED_CONTEXT) $out.=$section;\n        }\n        foreach($secondaryPaths as $path){\n            $content=$contents[$path]??''; $size=strlen($content);\n            // v2.6.0 — les dépendances secondaires servent à COMPRENDRE, pas\n            // encore à réécrire le fichier. On envoie donc un extrait pertinent\n            // et borné. Si le modèle doit modifier ce fichier, il doit demander\n            // explicitement type=path : il sera alors relu intégralement au cycle\n            // suivant et promu dans CONTEXTE OBLIGATOIRE.\n            $snippet=$this->snippet($content,$this->terms($request));\n            if(strlen($snippet)>self::MAX_SECONDARY_SNIPPET){\n                $snippet=AiTextEncodingService::slice($snippet,0,self::MAX_SECONDARY_SNIPPET).\"\\n...[extrait secondaire tronqué]...\";\n            }\n            $hash=hash('sha256',$content);\n            $block=\"\\n--- CONTEXT EXCERPT {$path} | {$size} octets | sha256={$hash} | NON_COMPLET ---\\n\".$snippet.\"\\n--- END CONTEXT EXCERPT {$path} ---\\n\";\n            if(strlen($out)+strlen($block)+strlen($footer)>self::MAX_PINNED_CONTEXT){\n                $omitted[]=$path;\n                continue;\n            }\n            $out.=$block;\n        }\n\n        if($omitted){\n            $note=\"\\nDEPENDANCES SECONDAIRES NON INJECTEES FAUTE DE BUDGET : \".implode(', ',array_slice($omitted,0,12)).\".\\n\";\n            if(strlen($out)+strlen($note)+strlen($footer)<=self::MAX_PINNED_CONTEXT) $out.=$note;\n        }\n\n        if(strlen($out)+strlen($footer)>self::MAX_PINNED_CONTEXT){\n            // Cas defensif : le header seul ne doit jamais rendre le payload invalide.\n            throw new \\RuntimeException('Code Explorer : en-tete du contexte verrouille trop volumineux pour le budget disponible.');\n        }\n\n        // v2.4.7 — garde-fou interne : chaque path explicite promu obligatoire\n        // doit réellement posséder son BEGIN/END COMPLET dans le payload qui\n        // sera remis au Finalizer. Cette vérification est indépendante de\n        // l'ordre d'exploration et donne un diagnostic précis avant l'appel IA.\n        foreach(array_keys($mandatoryPaths) as $path){\n            $content=$contents[$path]??'';\n            $size=strlen($content);\n            $hash=hash('sha256',$content);\n            $begin=\"--- BEGIN PINNED {$path} | {$size} octets | sha256={$hash} | COMPLET ---\";\n            $end=\"--- END PINNED {$path} | COMPLET ---\";\n            if(!str_contains($out,$begin) || !str_contains($out,$end)){\n                throw new \\RuntimeException(\n                    'Code Explorer : chemin explicite obligatoire non expose integralement avant Finalizer : '.\n                    $path.' ('.$size.' octets, sha256='.$hash.').'\n                );\n            }\n        }\n\n        return $out.$footer;\n    }\n\n    /**\n     * v2.4.1 — matérialise les dépendances autorisées (route/search/service/\n     * symbol/...) en chemins exacts réellement résolus. Ces chemins peuvent\n     * ensuite bénéficier de la garantie v2.3.6 (octets + SHA-256 + COMPLET).\n     */\n    public function materializeResolvedPathHints(string $request, array $hints): array\n    {\n        if (!$hints) return [];\n        $pinned=$this->explorePinnedDependencies($request,$hints);\n        if ($pinned==='') return [];\n        $out=[];$seen=[];\n        if (preg_match_all('/--- BEGIN PINNED ([^|\\r\\n]+?) \\| \\d+ octets \\| sha256=[a-f0-9]{64} \\| COMPLET ---/i',$pinned,$m)) {\n            foreach ($m[1] as $path) {\n                $path=trim(str_replace('\\\\','/',(string)$path),'/ ');\n                if ($path===''||isset($seen[$path])) continue;\n                $seen[$path]=true;$out[]=['type'=>'path','value'=>$path];\n            }\n        }\n        return $out;\n    }\n\n    /**\n     * v2.3.6 — garantit qu'un path explicite est présent COMPLET dans le\n     * contexte épinglé et que taille + SHA-256 correspondent aux octets relus\n     * sur le disque. Une simple mention du chemin ne suffit jamais.\n     */\n    public function assertExactPathsFullyPinned(array $hints, string $context): void\n    {\n        $missing=[];\n        foreach($this->normalizeDependencyHints($hints) as $hint){\n            if(($hint['type']??'')!=='path') continue;\n            $path=trim(str_replace('\\\\','/',(string)($hint['value']??'')),'/');\n            if($path===''||!$this->allowed($path)) continue;\n            $full=$this->projectRoot.$path;\n            if(!is_file($full)) continue;\n            $content=@file_get_contents($full);\n            if(!is_string($content)) throw new \\RuntimeException('Code Explorer : lecture impossible du fichier requis : '.$path);\n            $size=strlen($content); $hash=hash('sha256',$content);\n            $begin='--- BEGIN PINNED '.$path.' | '.$size.' octets | sha256='.$hash.' | COMPLET ---';\n            $end='--- END PINNED '.$path.' | COMPLET ---';\n            if(!str_contains($context,$begin)||!str_contains($context,$end)){\n                $missing[]=$path.' ('.$size.' octets, sha256='.$hash.')';\n            }\n        }\n        if($missing){\n            throw new \\RuntimeException('Code Explorer : contenu réel COMPLET non garanti dans le payload pour : '.implode(', ',array_slice($missing,0,8)).'.');\n        }\n    }\n\n    private function normalizeDependencyHints(array $hints): array\n    {\n        $out=[];\n        foreach ($hints as $hint) {\n            if (is_array($hint)) {\n                $type=strtolower(trim((string)($hint['type']??'')));\n                $value=trim((string)($hint['path']??$hint['value']??$hint['name']??$hint['method']??$hint['route']??$hint['table']??''));\n                if (in_array($type,['path','symbol','method','route','table','schema','migration','backup','install','rollback','search','service'],true) && $value!=='') $out[]=['type'=>$type,'value'=>$value];\n                continue;\n            }\n            $text=trim((string)$hint); if($text==='') continue;\n            if (preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+\\.(?:php|js|css|json|md)~i',$text,$m)) {\n                foreach($m[0] as $v) $out[]=['type'=>'path','value'=>$v];\n            }\n            if (preg_match_all('/\\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\\b/',$text,$m)) foreach($m[0] as $v) $out[]=['type'=>'symbol','value'=>$v];\n            if (preg_match_all('/\\b([A-Za-z_][A-Za-z0-9_]*)\\s*\\(\\)/',$text,$m)) foreach($m[1] as $v) $out[]=['type'=>'method','value'=>$v];\n            if (preg_match('~(?:route\\s+GET\\s+|route\\s+POST\\s+|route\\s+)(/?[A-Za-z0-9_./:-]+)~i',$text,$m)) $out[]=['type'=>'route','value'=>$m[1]];\n            if (preg_match_all('/\\b[a-z][a-z0-9_]{2,}\\b/',$text,$m)) {\n                foreach($m[0] as $v) if(str_contains($v,'_')) $out[]=['type'=>'table','value'=>$v];\n            }\n        }\n        $seen=[];$clean=[];\n        foreach($out as $h){$k=$h['type'].'|'.strtolower($h['value']);if(isset($seen[$k]))continue;$seen[$k]=1;$clean[]=$h;}\n        return array_slice($clean,0,24);\n    }\n\n    /**\n     * Extrait uniquement les termes explicitement demandés pour une recherche\n     * globale (\"occurrence de X\", \"chercher X\", \"rechercher X\").\n     */\n    /**\n     * v2.3.2 — transforme les demandes d'exploration en prose en dépendances\n     * structurées. Aucune cible n'est inventée : les chemins sont vérifiés et les\n     * tables/termes doivent être explicitement présents dans le texte.\n     */\n    private function normalizeNaturalExplorationPlan(string $summary): array\n    {\n        $root=rtrim($this->projectRoot,DIRECTORY_SEPARATOR);\n        $hints=[];$seen=[];\n        $add=static function(string $type,string $value) use (&$hints,&$seen): void {\n            $value=trim($value,\" \\t\\n\\r\\0\\x0B`\\\"\");\n            if($value==='') return;\n            $key=mb_strtolower($type.'|'.$value); if(isset($seen[$key])) return;\n            $seen[$key]=true; $hints[]=['type'=>$type,'value'=>$value];\n        };\n\n        // Chemins explicites, même lorsqu'ils sont seuls dans un bloc de code.\n        if(preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\\\.php|\\\\.js|\\\\.css|\\\\.json|\\\\.md)?~i',$summary,$m)){\n            foreach($m[0] as $path){\n                $path=trim(str_replace('\\\\\\\\','/',$path),'/');\n                if($this->readRelative($root,$path)!==null) $add('path',$path);\n            }\n        }\n\n        // « schéma réel de media », « structure réelle de la table media ».\n        if(preg_match_all('/(?:sch[ée]ma|structure)(?:\\\\s+(?:sql|r[ée]el(?:le)?|exacte?)){0,3}\\\\s+(?:de|du|pour)\\\\s+(?:la\\\\s+table\\\\s+)?[`\\\"\\']?([a-z][a-z0-9_]{2,})/iu',$summary,$m))\n            foreach($m[1] as $table) $add('schema',strtolower($table));\n        if(preg_match_all('/(?:table)\\\\s+[`\\\"\\']?([a-z][a-z0-9_]{2,})[`\\\"\\']?.{0,45}(?:sch[ée]ma|structure|colonnes?|index|contraintes?)/iu',$summary,$m))\n            foreach($m[1] as $table) $add('schema',strtolower($table));\n\n        // « migrations associées » hérite de la/les tables explicitement citées\n        // dans la même demande. Cela couvre le cas « schéma réel de media ; les\n        // migrations associées » sans demander au modèle de répéter media.\n        $schemaTables=[];\n        foreach($hints as $hint) if($hint['type']==='schema') $schemaTables[]=$hint['value'];\n        if($schemaTables && preg_match('/\\\\bmigrations?\\\\s+(?:associ[ée]es?|li[ée]es?|correspondantes?|concern[ée]es?)\\\\b/iu',$summary))\n            foreach($schemaTables as $table) $add('migration',$table);\n        if(preg_match_all('/\\\\bmigrations?\\\\s+(?:de|du|pour|li[ée]es?\\\\s+[àa])\\\\s+[`\\\"\\']?([a-z][a-z0-9_]{2,})/iu',$summary,$m))\n            foreach($m[1] as $table) $add('migration',strtolower($table));\n\n        // « recherches globales sur A, B, C et D ». Le segment est borné à la\n        // phrase/liste concernée puis découpé sur virgules, puces et « et ».\n        if(preg_match_all('/(?:recherches?\\\\s+globales?|rechercher\\\\s+globalement|chercher\\\\s+globalement)\\\\s+(?:sur|de|pour)?\\\\s*([^\\\\n.;]{2,500})/iu',$summary,$m)){\n            foreach($m[1] as $chunk){\n                $chunk=preg_replace('/\\\\s+(?:ainsi\\\\s+que|et)\\\\s+/iu',',',$chunk)??$chunk;\n                foreach(preg_split('/\\\\s*[,•]\\\\s*/u',$chunk)?:[] as $term){\n                    $term=trim($term,\" \\t\\n\\r\\0\\x0B`\\\"\");\n                    $term=preg_replace('/^(?:les?|la|des?|toutes?\\\\s+les?)\\\\s+/iu','',$term)??$term;\n                    if($term!=='' && mb_strlen($term)>=2 && mb_strlen($term)<=80) $add('search',$term);\n                }\n            }\n        }\n\n        // Termes techniques explicitement cités après une intention de recherche.\n        if(preg_match('/(?:recherches?\\\\s+globales?|occurrences?|chercher|rechercher)/iu',$summary)){\n            foreach(['MediaModel','stored_name',\"WRITEPATH.'uploads'\",'WRITEPATH.\"uploads\"','unlink','delete()','delete','uploads/'] as $candidate)\n                if(stripos($summary,$candidate)!==false) $add('search',$candidate);\n        }\n\n        return array_slice($hints,0,16);\n    }\n\n    private function extractRequestedSearchTerms(string $summary): array\n    {\n        $terms=[];\n        $patterns=[\n            '/(?:occurrences?|recherche(?:r)?|chercher|trouver)\\s+(?:de|du|des|pour)?\\s*([`\\'\\\"]?)([^\\n,;•]{2,80})\\1/iu',\n        ];\n        foreach($patterns as $pattern){\n            if(!preg_match_all($pattern,$summary,$matches,PREG_SET_ORDER)) continue;\n            foreach($matches as $match){\n                $chunk=trim((string)($match[2]??''));\n                $chunk=preg_replace('/^(?:toute|toutes|tout|les|la|le|une|un)\\s+/iu','',$chunk)??$chunk;\n                // Une liste \"A, B et C\" est traitée plus bas via les tokens cités.\n                if($chunk!=='' && mb_strlen($chunk)<=80) $terms[]=$chunk;\n            }\n        }\n        // Les identifiants/code cités explicitement sont les cibles les plus sûres.\n        if(preg_match_all('/`([^`]{2,80})`/u',$summary,$m)) foreach($m[1] as $v) $terms[]=trim($v);\n        // Cas fréquent sans backticks dans une liste d'occurrences.\n        if(preg_match('/occurrences?/iu',$summary)){\n            foreach(['MediaModel','delete','unlink',\"WRITEPATH.'uploads'\",'WRITEPATH.\"uploads\"','path','stored_name'] as $candidate){\n                if(stripos($summary,$candidate)!==false) $terms[]=$candidate;\n            }\n        }\n        $clean=[];$seen=[];\n        foreach($terms as $term){\n            $term=trim($term,\" \\t\\n\\r\\0\\x0B`'\\\"\");\n            if($term==='' || mb_strlen($term)<2 || mb_strlen($term)>80) continue;\n            $key=mb_strtolower($term); if(isset($seen[$key])) continue;\n            $seen[$key]=true; $clean[]=$term;\n        }\n        return array_slice($clean,0,10);\n    }\n\n    /** Recherche littérale bornée aux racines autorisées, sans shell ni regex utilisateur. */\n    private function resolveSearchPaths(string $root,string $needle): array\n    {\n        $needle=trim($needle); if($needle==='' || mb_strlen($needle)>80) return [];\n        $matches=[];\n        foreach($this->roots as $relativeRoot){\n            $base=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$relativeRoot);\n            if(!is_dir($base)) continue;\n            $it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($base,\\FilesystemIterator::SKIP_DOTS));\n            foreach($it as $file){\n                if(!$file->isFile() || $file->getSize()>self::MAX_FILE_SIZE) continue;\n                $ext=strtolower($file->getExtension()); if(!in_array($ext,$this->extensions,true)) continue;\n                $path=$this->relative($file->getPathname()); if(!$this->allowed($path)) continue;\n                $content=@file_get_contents($file->getPathname()); if(!is_string($content)) continue;\n                $count=substr_count(mb_strtolower($content),mb_strtolower($needle)); if($count<1) continue;\n                $score=min($count,20)*3;\n                if(str_starts_with($path,'app/Controllers/')) $score+=12;\n                elseif(str_starts_with($path,'app/Models/')) $score+=11;\n                elseif(str_starts_with($path,'app/Services/')) $score+=10;\n                elseif(str_starts_with($path,'app/Database/Migrations/')) $score+=8;\n                if(str_contains(mb_strtolower(basename($path)),mb_strtolower(preg_replace('/[^A-Za-z0-9_]/','',$needle)??$needle))) $score+=8;\n                $matches[]=['path'=>$path,'score'=>$score];\n            }\n        }\n        usort($matches,static fn(array $a,array $b): int => ($b['score']<=>$a['score']) ?: strcmp($a['path'],$b['path']));\n        return array_column(array_slice($matches,0,6),'path');\n    }\n\n    /** Résout les services de gestion de fichiers sans inventer de classe. */\n    private function resolveServicePaths(string $root,string $value): array\n    {\n        $serviceRoot=$root.DIRECTORY_SEPARATOR.'app/Services'; if(!is_dir($serviceRoot)) return [];\n        $tokens=$value==='file-management'\n            ? ['file','media','upload','storage','image','document','delete','unlink','path','stored_name']\n            : array_values(array_filter(preg_split('/[^A-Za-z0-9_]+/',mb_strtolower($value))?:[]));\n        $ranked=[];\n        foreach(new \\DirectoryIterator($serviceRoot) as $file){\n            if(!$file->isFile() || strtolower($file->getExtension())!=='php' || $file->getSize()>self::MAX_FILE_SIZE) continue;\n            $path='app/Services/'.$file->getFilename(); $content=$this->readRelative($root,$path); if($content===null) continue;\n            $hay=mb_strtolower($file->getFilename().' '.$content); $score=0;\n            foreach($tokens as $token) if($token!=='' && str_contains($hay,mb_strtolower($token))) $score++;\n            if($score>0) $ranked[]=['path'=>$path,'score'=>$score];\n        }\n        usort($ranked,static fn(array $a,array $b): int => ($b['score']<=>$a['score']) ?: strcmp($a['path'],$b['path']));\n        return array_column(array_slice($ranked,0,5),'path');\n    }\n\n    private function pinPath(string $root,string $path,array &$selected,array &$contents,array &$chain,string $reason): void\n    {\n        $path=trim(str_replace('\\\\','/',$path),'/');\n        $content=$this->readRelative($root,$path); if($content===null)return;\n        $this->addFile($selected,$contents,$path,$content); $chain[]=$reason.' -> '.$path;\n    }\n\n    /** Lit la propriété protected $table d'un Model réel. */\n    private function resolveModelTable(string $root,string $symbol): ?string\n    {\n        if(!str_ends_with($symbol,'Model')) return null;\n        foreach($this->resolveSymbolPaths($root,$symbol) as $path){\n            $content=$this->readRelative($root,$path);\n            if($content===null) continue;\n            if(preg_match('/protected\\s+\\$table\\s*=\\s*[\\'\"]([A-Za-z0-9_]+)[\\'\"]\\s*;/i',$content,$m)){\n                $table=preg_replace('/[^A-Za-z0-9_]/','',(string)$m[1]);\n                if($table!=='') return $table;\n            }\n        }\n        return null;\n    }\n\n    private function resolveSymbolPaths(string $root,string $symbol): array\n    {\n        $symbol=preg_replace('/[^A-Za-z0-9_]/','',$symbol); if($symbol==='')return [];\n        $roots=['app/Models','app/Services','app/Controllers'];$matches=[];\n        foreach($roots as $base){$dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base);if(!is_dir($dir))continue;$it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));foreach($it as $f){if($f->isFile()&&strcasecmp($f->getFilename(),$symbol.'.php')===0)$matches[]=$this->relative($f->getPathname());}}\n        return array_values(array_unique($matches));\n    }\n\n    private function resolveMethodPaths(string $root,string $method): array\n    {\n        $method=preg_replace('/[^A-Za-z0-9_]/','',$method); if($method==='')return [];$matches=[];\n        $dir=$root.DIRECTORY_SEPARATOR.'app/Controllers'; if(!is_dir($dir))return [];\n        $it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));\n        foreach($it as $f){if(!$f->isFile()||strtolower($f->getExtension())!=='php')continue;$c=@file_get_contents($f->getPathname());if(is_string($c)&&preg_match('/function\\s+'.preg_quote($method,'/').'\\s*\\(/i',$c))$matches[]=$this->relative($f->getPathname());}\n        return array_slice(array_values(array_unique($matches)),0,6);\n    }\n\n    private function resolveLifecyclePaths(string $root,string $type,string $value=''): array\n    {\n        $needles=match($type){\n            'backup'=>['backup','backups','DevelopmentDatabaseBackupService'],\n            'rollback'=>['rollback','restore','restoreFiles','DevelopmentDatabaseBackupService'],\n            default=>['install','migrations','DevelopmentProposalService'],\n        };\n        $matches=[];\n        // Le service de proposition est la source de vérité du cycle\n        // prévalidation -> backup -> installation -> migration -> rollback.\n        foreach(['app/Services/DevelopmentProposalService.php','app/Services/DevelopmentDatabaseBackupService.php'] as $known){\n            $c=$this->readRelative($root,$known);\n            if($c!==null){foreach($needles as $n){if(stripos($c,$n)!==false){$matches[]=$known;break;}}}\n        }\n        foreach(['app/Services','app/Controllers/Admin'] as $base){\n            $dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base); if(!is_dir($dir)) continue;\n            $it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));\n            foreach($it as $f){\n                if(!$f->isFile()||strtolower($f->getExtension())!=='php') continue;\n                $c=@file_get_contents($f->getPathname()); if(!is_string($c)) continue;\n                foreach($needles as $n){ if(stripos($c,$n)!==false){$matches[]=$this->relative($f->getPathname());break;} }\n                if(count($matches)>=10) break 2;\n            }\n        }\n        return array_slice(array_values(array_unique($matches)),0,10);\n    }\n\n    private function resolveTablePaths(string $root,string $table): array\n    {\n        $table=preg_replace('/[^A-Za-z0-9_]/','',$table);if($table==='')return [];$matches=[];\n        foreach(['app/Database/Migrations','app/Models'] as $base){$dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base);if(!is_dir($dir))continue;$it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));foreach($it as $f){if(!$f->isFile()||strtolower($f->getExtension())!=='php')continue;$c=@file_get_contents($f->getPathname());if(is_string($c)&&stripos($c,$table)!==false)$matches[]=$this->relative($f->getPathname());}}\n        return array_values(array_unique($matches));\n    }\n\n    /**\n     * Retrouve la route correspondant exactement à la page source connue du\n     * Development Engine. Cela permet de continuer l'exploration même lorsque\n     * la formulation utilisateur ne contient aucun nom de module ou d'URL.\n     */\n    private function findRoutesByUri(string $content, string $sourceUri): array\n    {\n        $sourceUri = trim($sourceUri, '/');\n        if ($sourceUri === '') {\n            return [];\n        }\n\n        $matches = [];\n        foreach ($this->routeDefinitions($content) as $route) {\n            if (trim((string) $route['uri'], '/') !== $sourceUri) {\n                continue;\n            }\n            $route['score'] = 1000;\n            $matches[] = $route;\n        }\n        return $matches;\n    }\n\n    /**\n     * Cherche les routes dont l'URI complet ou la cible correspond aux termes utilisateur.\n     * Les groupes CodeIgniter sont reconstruits (préfixe + namespace) afin que\n     * \"mises-a-jour\" dans group('admin', ...) devienne réellement\n     * \"/admin/mises-a-jour\" et App\\\\Controllers\\\\Admin\\\\Updates.\n     */\n    private function findMatchingRoutes(string $content, array $terms): array\n    {\n        $matches = [];\n        $asciiRequestTerms = array_values(array_unique(array_filter(array_map(fn($t) => $this->ascii($t), $terms))));\n\n        foreach ($this->routeDefinitions($content) as $route) {\n            $haystack = mb_strtolower((string) $route['uri'].' '.(string) $route['controller'].'::'.(string) $route['method']);\n            $asciiHaystack = $this->ascii($haystack);\n            $score = 0;\n            foreach ($terms as $term) {\n                if ($term !== '' && str_contains($haystack, $term)) {\n                    $score += 5;\n                }\n            }\n            foreach ($asciiRequestTerms as $term) {\n                if ($term !== '' && str_contains($asciiHaystack, $term)) {\n                    $score += 4;\n                }\n            }\n            if ($score === 0) {\n                continue;\n            }\n            $route['score'] = $score;\n            $matches[] = $route;\n        }\n\n        usort($matches, static fn(array $a, array $b): int => $b['score'] <=> $a['score']);\n        return array_slice($matches, 0, 4);\n    }\n\n    /**\n     * Extrait les routes directes et celles placées dans un group() simple.\n     * Cette lecture est volontairement déterministe et limitée au format utilisé\n     * par evolution afin d'éviter toute invention de namespace ou de chemin.\n     */\n    private function routeDefinitions(string $content): array\n    {\n        $routes = [];\n        $covered = [];\n\n        if (preg_match_all('/\\\\$routes->group\\\\(\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\\\s*,\\\\s*(\\\\[[^;]*?\\\\])?\\\\s*,\\\\s*static\\\\s+function\\\\s*\\\\(\\\\$routes\\\\)\\\\s*\\\\{/is', $content, $groups, PREG_OFFSET_CAPTURE)) {\n            foreach ($groups[0] as $i => $whole) {\n                $groupStart = $whole[1];\n                $openBrace = $groupStart + strrpos($whole[0], '{');\n                $closeBrace = $this->matchingBrace($content, $openBrace);\n                if ($closeBrace === null) {\n                    continue;\n                }\n                $prefix = trim((string) $groups[1][$i][0], '/');\n                $options = (string) ($groups[2][$i][0] ?? '');\n                $namespace = '';\n                if (preg_match('/[\\'\\\"]namespace[\\'\\\"]\\\\s*=>\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]/i', $options, $m)) {\n                    $namespace = trim(str_replace('\\\\\\\\', '\\\\', $m[1]), '\\\\');\n                }\n                $body = substr($content, $openBrace + 1, $closeBrace - $openBrace - 1);\n                foreach ($this->parseRouteCalls($body, $prefix, $namespace) as $route) {\n                    $routes[] = $route;\n                }\n                $covered[] = [$groupStart, $closeBrace + 1];\n            }\n        }\n\n        // Routes hors groupes. Les appels déjà compris dans un groupe sont ignorés.\n        if (preg_match_all('/\\\\$routes->(get|post|put|patch|delete|options|match)\\\\(\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\\\s*,\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]/i', $content, $rows, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {\n            foreach ($rows as $row) {\n                $offset = $row[0][1];\n                $inside = false;\n                foreach ($covered as [$a, $b]) {\n                    if ($offset >= $a && $offset < $b) { $inside = true; break; }\n                }\n                if ($inside) continue;\n                $parsed = $this->normalizeRoute($row[1][0], $row[2][0], $row[3][0], '', '');\n                if ($parsed !== null) $routes[] = $parsed;\n            }\n        }\n\n        return $routes;\n    }\n\n    private function parseRouteCalls(string $body, string $prefix, string $namespace): array\n    {\n        $routes = [];\n        if (!preg_match_all('/\\\\$routes->(get|post|put|patch|delete|options|match)\\\\(\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\\\s*,\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]/i', $body, $rows, PREG_SET_ORDER)) {\n            return [];\n        }\n        foreach ($rows as $row) {\n            $route = $this->normalizeRoute($row[1], $row[2], $row[3], $prefix, $namespace);\n            if ($route !== null) $routes[] = $route;\n        }\n        return $routes;\n    }\n\n    private function normalizeRoute(string $verb, string $uri, string $target, string $prefix, string $namespace): ?array\n    {\n        $target = preg_replace('~/\\\\$\\\\d+.*$~', '', $target) ?? $target;\n        if (!str_contains($target, '::')) return null;\n        [$controller, $method] = explode('::', $target, 2);\n        $controller = trim($controller, '\\\\');\n        if ($namespace !== '' && !str_contains($controller, '\\\\')) {\n            $controller = $namespace.'\\\\'.$controller;\n        }\n        $fullUri = trim(trim($prefix, '/').'/'.trim($uri, '/'), '/');\n        if ($prefix === '') $fullUri = trim($uri, '/');\n        return [\n            'verb' => strtolower($verb),\n            'uri' => $fullUri,\n            'controller' => $controller,\n            'method' => preg_replace('/[^A-Za-z0-9_].*$/', '', $method) ?: $method,\n            'score' => 0,\n        ];\n    }\n\n    private function matchingBrace(string $content, int $openBrace): ?int\n    {\n        $depth = 0; $inSingle = false; $inDouble = false; $escape = false; $len = strlen($content);\n        for ($i = $openBrace; $i < $len; $i++) {\n            $c = $content[$i];\n            if ($escape) { $escape = false; continue; }\n            if (($inSingle || $inDouble) && $c === '\\\\') { $escape = true; continue; }\n            if (!$inDouble && $c === \"'\") { $inSingle = !$inSingle; continue; }\n            if (!$inSingle && $c === '\"') { $inDouble = !$inDouble; continue; }\n            if ($inSingle || $inDouble) continue;\n            if ($c === '{') $depth++;\n            elseif ($c === '}') {\n                $depth--;\n                if ($depth === 0) return $i;\n            }\n        }\n        return null;\n    }\n\n    /**\n     * Résout le chemin réel d'un contrôleur.\n     *\n     * Dans CodeIgniter, une route située dans un group() avec namespace\n     * peut contenir simplement \"Development::index\" alors que le fichier\n     * réel est app/Controllers/Admin/Development.php. On ne doit donc pas\n     * déduire aveuglément app/Controllers/Development.php.\n     */\n    private function resolveControllerPath(string $root, string $controller): string\n    {\n        $normalized = str_replace('\\\\', '/', trim($controller, '\\\\'));\n        if (str_starts_with($normalized, 'App/Controllers/')) {\n            $normalized = substr($normalized, strlen('App/Controllers/'));\n        }\n        $candidates = [\n            'app/Controllers/' . $normalized . '.php',\n            'app/Controllers/Admin/' . $normalized . '.php',\n        ];\n\n        foreach (array_values(array_unique($candidates)) as $candidate) {\n            if ($this->readRelative($root, $candidate) !== null) {\n                return $candidate;\n            }\n        }\n\n        // Dernier recours sûr : retrouver un fichier de même nom sous\n        // app/Controllers. On n'accepte le résultat que s'il est unique.\n        $basename = basename($normalized) . '.php';\n        $controllersRoot = $root . DIRECTORY_SEPARATOR . 'app/Controllers';\n        $matches = [];\n        if (is_dir($controllersRoot)) {\n            $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($controllersRoot, \\FilesystemIterator::SKIP_DOTS));\n            foreach ($it as $file) {\n                if (!$file->isFile() || $file->getFilename() !== $basename) {\n                    continue;\n                }\n                $relative = $this->relative($file->getPathname());\n                if ($this->allowed($relative)) {\n                    $matches[] = $relative;\n                }\n            }\n        }\n\n        return count($matches) === 1 ? $matches[0] : 'app/Controllers/' . $normalized . '.php';\n    }\n\n    private function extractMethodBlock(string $content, string $method): string\n    {\n        $pattern = '/(?:public|protected|private)\\\\s+function\\\\s+' . preg_quote($method, '/') . '\\\\s*\\\\([^)]*\\\\)\\\\s*(?::\\\\s*[^\\\\{]+)?\\\\{/i';\n        if (!preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {\n            return '';\n        }\n\n        $start = $match[0][1];\n        $brace = strpos($content, '{', $start);\n        if ($brace === false) {\n            return '';\n        }\n\n        $depth = 0;\n        $length = strlen($content);\n        for ($i = $brace; $i < $length; $i++) {\n            if ($content[$i] === '{') {\n                $depth++;\n            } elseif ($content[$i] === '}') {\n                $depth--;\n                if ($depth === 0) {\n                    return substr($content, $start, $i - $start + 1);\n                }\n            }\n        }\n\n        return AiTextEncodingService::slice($content, $start, min(self::MAX_SNIPPET * 2, $length - $start));\n    }\n\n    private function extractViews(string $content): array\n    {\n        $views = [];\n        if (preg_match_all(\"/\\\\bview\\\\(\\\\s*['\\\"]([^'\\\"]+)['\\\"]/\", $content, $matches)) {\n            $views = array_merge($views, $matches[1]);\n        }\n        if (preg_match_all(\"/\\\\breturn\\\\s+view\\\\(\\\\s*['\\\"]([^'\\\"]+)['\\\"]/\", $content, $matches)) {\n            $views = array_merge($views, $matches[1]);\n        }\n        return array_values(array_unique(array_filter(array_map('trim', $views))));\n    }\n\n    private function extractAssetPaths(string $viewContent): array\n    {\n        $paths = [];\n\n        if (preg_match_all(\"/(?:src|href)\\\\s*=\\\\s*['\\\"](?:<?=\\\\s*base_url\\\\(['\\\"])?\\/?([^'\\\"?]+\\\\.(?:js|css))[^'\\\"]*['\\\"]/i\", $viewContent, $matches)) {\n            foreach ($matches[1] as $path) {\n                $path = ltrim($path, '/');\n                if (str_starts_with($path, 'assets/')) {\n                    $paths[] = 'public/' . $path;\n                } elseif (str_starts_with($path, 'public/assets/')) {\n                    $paths[] = $path;\n                }\n            }\n        }\n\n        // Cas fréquent dans evolution : <script src=\"/assets/js/foo.js\"></script>\n        if (preg_match_all(\"~['\\\"]/?assets/(?:js|css)/[^'\\\"?]+\\\\.(?:js|css)~i\", $viewContent, $matches)) {\n            foreach ($matches[0] as $raw) {\n                $raw = trim($raw, \"'\\\"\");\n                $paths[] = 'public/' . ltrim($raw, '/');\n            }\n        }\n\n        return array_values(array_unique($paths));\n    }\n\n    private function findCssForView(string $root, string $viewContent): array\n    {\n        $classes = [];\n        if (preg_match_all('/class\\\\s*=\\\\s*[\"\\']([^\"\\']+)[\"\\']/i', $viewContent, $matches)) {\n            foreach ($matches[1] as $classList) {\n                foreach (preg_split('/\\\\s+/', trim($classList)) ?: [] as $class) {\n                    // On ne recherche que les classes suffisamment spécifiques,\n                    // pas toutes les classes Bootstrap génériques.\n                    if (strlen($class) >= 6 && (str_starts_with($class, 'dev-') || str_starts_with($class, 'evolution-') || str_contains($class, 'prompt'))) {\n                        $classes[$class] = true;\n                    }\n                }\n            }\n        }\n\n        if (!$classes) {\n            return [];\n        }\n\n        $result = [];\n        $cssDir = $root . DIRECTORY_SEPARATOR . 'public/assets';\n        if (!is_dir($cssDir)) {\n            return [];\n        }\n\n        $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($cssDir, \\FilesystemIterator::SKIP_DOTS));\n        foreach ($it as $file) {\n            if (!$file->isFile() || strtolower($file->getExtension()) !== 'css' || $file->getSize() > self::MAX_FILE_SIZE) {\n                continue;\n            }\n            $content = @file_get_contents($file->getPathname());\n            if (!is_string($content)) {\n                continue;\n            }\n            foreach (array_keys($classes) as $class) {\n                if (str_contains($content, '.' . $class)) {\n                    $result[$this->relative($file->getPathname())] = $content;\n                    break;\n                }\n            }\n        }\n\n        return $result;\n    }\n\n    /**\n     * Recherche déterministe des libellés significatifs présents dans la demande.\n     * Les mots génériques sont ignorés : le but est de retrouver le texte réellement\n     * visible dans une vue, pas de deviner un nom de fichier.\n     */\n    private function literalMatches(string $root, string $request): array\n    {\n        $needles = [];\n        if (preg_match_all('/[\"“”«»\\']([^\"“”«»\\']{3,80})[\"“”«»\\']/u', $request, $m)) {\n            foreach ($m[1] as $value) $needles[] = trim($value);\n        }\n\n        // Ajouter les groupes de mots avec majuscule, très fréquents pour les titres\n        // et libellés d'interface : Administration, Mises à jour, Évolution IA, etc.\n        if (preg_match_all('/\\b[\\p{Lu}][\\p{L}\\p{N}À-ÿ_-]*(?:\\s+(?:à|de|des|du|la|le|les|IA|[\\p{Lu}][\\p{L}\\p{N}À-ÿ_-]*)){0,3}\\b/u', $request, $m)) {\n            foreach ($m[0] as $value) $needles[] = trim($value);\n        }\n\n        $stop = ['Je','Le','La','Les','Un','Une','Il','Elle','Pour','Dans','Sur','Page','Titre','Icon','Icône','Proposition'];\n        $needles = array_values(array_unique(array_filter($needles, static fn(string $v): bool => mb_strlen($v) >= 4 && !in_array($v, $stop, true))));\n        usort($needles, static fn(string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a));\n        $needles = array_slice($needles, 0, 8);\n        if (!$needles) return [];\n\n        $scored = [];\n        foreach ($this->roots as $allowedRoot) {\n            $dir = $root . DIRECTORY_SEPARATOR . $allowedRoot;\n            if (!is_dir($dir)) continue;\n            $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir, \\FilesystemIterator::SKIP_DOTS));\n            foreach ($it as $file) {\n                if (!$file->isFile() || $file->getSize() > self::MAX_FILE_SIZE) continue;\n                if (!in_array(strtolower($file->getExtension()), $this->extensions, true)) continue;\n                $path = $this->relative($file->getPathname());\n                if (!$this->allowed($path)) continue;\n                $content = @file_get_contents($file->getPathname());\n                if (!is_string($content)) continue;\n                $score = 0;\n                foreach ($needles as $needle) {\n                    $count = substr_count(mb_strtolower($content), mb_strtolower($needle));\n                    if ($count) $score += 20 + min($count, 5) * 3 + min(mb_strlen($needle), 30);\n                }\n                if ($score) $scored[$path] = ['score'=>$score, 'content'=>$content];\n            }\n        }\n        uasort($scored, static fn(array $a, array $b): int => $b['score'] <=> $a['score']);\n        $out=[];\n        foreach (array_slice($scored, 0, 4, true) as $path=>$row) $out[$path]=$row['content'];\n        return $out;\n    }\n\n    /** Suit sans écriture les références explicites découvertes dans une vue. */\n    private function followFileReferences(string $root, string $path, string $content, array &$selected, array &$contents, array &$forcedSnippets, array &$chain): void\n    {\n        if (!str_starts_with($path, 'app/Views/')) return;\n        $views = $this->extractViews($content);\n        if (preg_match_all('/\\$this->(?:extend|include)\\(\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\s*\\)/', $content, $m)) {\n            $views = array_merge($views, $m[1]);\n        }\n        foreach (array_values(array_unique($views)) as $view) {\n            if (str_contains($view, '$') || str_contains($view, '{')) continue;\n            $viewPath='app/Views/'.trim($view,'/').'.php';\n            $viewContent=$this->readRelative($root,$viewPath);\n            if ($viewContent===null) continue;\n            $this->addFile($selected,$contents,$viewPath,$viewContent);\n            $forcedSnippets[$viewPath]=$viewContent;\n            $chain[]='Référence de vue suivie automatiquement -> '.$viewPath;\n        }\n        foreach ($this->extractAssetPaths($content) as $assetPath) {\n            $assetContent=$this->readRelative($root,$assetPath);\n            if ($assetContent===null) continue;\n            $this->addFile($selected,$contents,$assetPath,$assetContent);\n            $chain[]='Asset suivi automatiquement -> '.$assetPath;\n        }\n    }\n\n    private function scoreProjectFiles(string $root, array $terms): array\n    {\n        $scores = [];\n\n        foreach ($this->roots as $allowedRoot) {\n            $dir = $root . DIRECTORY_SEPARATOR . $allowedRoot;\n            if (!is_dir($dir)) {\n                continue;\n            }\n\n            $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir, \\FilesystemIterator::SKIP_DOTS));\n            foreach ($it as $file) {\n                if (!$file->isFile() || $file->getSize() > self::MAX_FILE_SIZE) {\n                    continue;\n                }\n                $ext = strtolower($file->getExtension());\n                if (!in_array($ext, $this->extensions, true)) {\n                    continue;\n                }\n\n                $path = $this->relative($file->getPathname());\n                if (!$this->allowed($path)) {\n                    continue;\n                }\n\n                $content = @file_get_contents($file->getPathname());\n                if (!is_string($content)) {\n                    continue;\n                }\n\n                $haystack = mb_strtolower($path . \"\\n\" . $content);\n                $asciiHaystack = $this->ascii($haystack);\n                $score = 0;\n                foreach ($terms as $term) {\n                    $count = substr_count($haystack, $term);\n                    if ($count > 0) {\n                        $score += min($count, 8);\n                    }\n                    $asciiTerm = $this->ascii($term);\n                    if ($asciiTerm !== '' && $asciiTerm !== $term && str_contains($asciiHaystack, $asciiTerm)) {\n                        $score += 2;\n                    }\n                    if (str_contains(mb_strtolower($path), $term)) {\n                        $score += 8;\n                    }\n                }\n                if ($score > 0) {\n                    $scores[$path] = $score;\n                }\n            }\n        }\n\n        arsort($scores);\n        return $scores;\n    }\n\n    /**\n     * Les fichiers déterministes (contrôleur, vue, layout) doivent être transmis\n     * intégralement lorsqu'ils restent de taille raisonnable. Un simple snippet de\n     * 30 Ko pouvait auparavant couper une méthode au milieu et empêcher l'IA de\n     * produire un diff fiable.\n     */\n    private function targetContent(string $content): string\n    {\n        if (strlen($content) <= self::MAX_TARGET_CONTENT) {\n            return $content;\n        }\n\n        // Pour les fichiers exceptionnellement volumineux, conserver des blocs\n        // clairement signalés plutôt que de prétendre avoir fourni le fichier entier.\n        $half = intdiv(self::MAX_TARGET_CONTENT, 2);\n        return AiTextEncodingService::slice($content, 0, $half)\n            . \"\\n\\n/* === evolution: partie centrale omise (fichier > limite ciblée) === */\\n\\n\"\n            . AiTextEncodingService::slice($content, -$half);\n    }\n\n    private function addFile(array &$selected, array &$contents, string $path, string $content): void\n    {\n        if (!$this->allowed($path) || isset($contents[$path])) {\n            return;\n        }\n        $selected[] = $path;\n        $contents[$path] = $content;\n    }\n\n    private function readRelative(string $root, string $path): ?string\n    {\n        if (!$this->allowed($path)) {\n            return null;\n        }\n\n        $full = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);\n        if (!is_file($full) || filesize($full) > self::MAX_FILE_SIZE) {\n            return null;\n        }\n\n        $realRoot = realpath($root);\n        $realFull = realpath($full);\n        if ($realRoot === false || $realFull === false || !str_starts_with($realFull, $realRoot . DIRECTORY_SEPARATOR)) {\n            return null;\n        }\n\n        $content = @file_get_contents($realFull);\n        return is_string($content) ? $content : null;\n    }\n\n    private function terms(string $request): array\n    {\n        $text = mb_strtolower(strip_tags($request));\n        $words = preg_split('/[^\\\\p{L}\\\\p{N}_-]+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];\n        $stop = [\n            'avec','dans','pour','mais','plus','cette','cela','comme','faire','mettre','mise','jour','actuel','actuelle',\n            'être','avoir','tout','tous','toute','toutes','une','des','les','que','qui','sur','mon','ma','mes','du','de','la',\n            'le','un','et','ou','est','sont','je','tu','il','elle','nous','vous','ils','elles','trop','petit','petite','page',\n            'formulaire','analyse','analyser','propose','proposer','amélioration','amelioration','retrouve','retrouver','même','meme'\n        ];\n        $words = array_values(array_filter(\n            $words,\n            static fn(string $word): bool => mb_strlen($word) >= 3 && !in_array($word, $stop, true)\n        ));\n\n        // Ajouter les variantes ASCII facilite evolution <-> évolution.\n        $terms = [];\n        foreach ($words as $word) {\n            $terms[] = $word;\n            $ascii = $this->ascii($word);\n            if ($ascii !== '' && $ascii !== $word) {\n                $terms[] = $ascii;\n            }\n        }\n\n        return array_slice(array_values(array_unique($terms)), 0, 12);\n    }\n\n    private function snippet(string $content, array $terms): string\n    {\n        if (strlen($content) <= self::MAX_SNIPPET) {\n            return $content;\n        }\n\n        $lower = mb_strtolower($content);\n        $position = false;\n        foreach ($terms as $term) {\n            $pos = mb_strpos($lower, $term);\n            if ($pos !== false) {\n                $position = $pos;\n                break;\n            }\n        }\n\n        if ($position === false) {\n            return AiTextEncodingService::slice($content, 0, self::MAX_SNIPPET);\n        }\n\n        $prefix = mb_substr($content, 0, max(0, $position - 800));\n        $bytePosition = strlen($prefix);\n        return AiTextEncodingService::slice($content, max(0, $bytePosition), self::MAX_SNIPPET);\n    }\n\n    private function ascii(string $value): string\n    {\n        $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);\n        return mb_strtolower(is_string($converted) ? $converted : $value);\n    }\n\n    private function relative(string $path): string\n    {\n        $root = rtrim(str_replace('\\\\', '/', $this->projectRoot), '/');\n        return ltrim(substr(str_replace('\\\\', '/', $path), strlen($root)), '/');\n    }\n\n    private function allowed(string $path): bool\n    {\n        $path = ltrim(str_replace('\\\\', '/', $path), '/');\n        if ($path === '' || str_contains($path, '..')) {\n            return false;\n        }\n\n        foreach (['.env', '.git/', '.ssh/', 'writable/', 'public/uploads/'] as $blocked) {\n            if (str_starts_with($path, $blocked) || str_contains($path, '/' . $blocked)) {\n                return false;\n            }\n        }\n\n        $inReadableRoot = str_starts_with($path, 'app/') || str_starts_with($path, 'public/') || str_starts_with($path, 'scripts/') || str_starts_with($path, 'install/');\n        $rootReadable = in_array($path, ['composer.json','composer.lock','spark','.htaccess','env.example','env.ovh.example','README-RESTORE.md'], true);\n        if (!$inReadableRoot && !$rootReadable) return false;\n        if ($rootReadable) return true;\n        $base = strtolower(basename($path));\n        if ($base === '.htaccess') return true;\n        $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));\n        return in_array($ext, ['php','js','css','json','md','txt','sql','xml','yml','yaml','ini','lock','example','dist','sh','svg'], true);\n    }\n}\n\n--- END PINNED app/Services/DevelopmentCodeExplorerService.php | COMPLET ---\n\nREGLE FINALE : ne demande jamais de relire un fichier marque COMPLET ci-dessus. Produis maintenant files[]/database ou indique uniquement une dependance concrete absente.\nCONTEXTE TECHNIQUE evolution (généré automatiquement)\nVersion installée : 3.10.5\nFramework : CodeIgniter 4\nUI : Bootstrap 5 + Bootstrap Icons\nBase : MySQL/MariaDB\nAPI IA : OpenAI Responses API avec streaming\nHébergement : environnement web PHP, déploiement compatible OVH mutualisé\nMise à jour : gestionnaire ZIP versionné avec migrations CodeIgniter, sauvegarde préalable et historique app_versions.\nFichiers/données protégés : .env, .env.ovh, .git, .ssh, writable, public/uploads.\nTables actuellement visibles : dev_ai_access, dev_ai_access_logs, dev_ai_access_requests, dev_ai_usage_logs, dev_conversations, dev_logs, dev_messages, dev_project_knowledge, dev_proposal_database, dev_proposal_files, dev_proposal_git_states, dev_proposals, migrations, password_resets, settings, users.\nImportant : l’assistant utilisateur /app est distinct de ce Development Engine d’administration. Ne pas mélanger leurs conversations ni leurs prompts.",
    "input": "PHASE FINALISATION evolution. INTENTION RACINE VERROUILLEE sha256=a0c379af102d35469837e7c0099172efdb3070177a04de642cdf00d3ee4096d6. Le contexte technique est final et déjà exploré. Réponds UNIQUEMENT avec un objet JSON valide, sans markdown. Schéma autorisé: {\"status\":\"proposal|no_change|needs_context\",\"title\":string,\"summary\":string,\"files\":[{\"path\":string,\"action\":\"modify|create\",\"content\":string,\"edits\":[{\"symbol\":\"method:nom\",\"replace\":string}|{\"search\":string,\"replace\":string}|{\"start\":string,\"end\":string,\"replace\":string}]}],\"database\":null|{\"mode\":\"migration\",\"summary\":string,\"migration_path\":string,\"content\":string,\"risk_level\":\"low|medium|high\",\"reversible\":bool},\"needs_context\":[{\"type\":\"path|symbol|method|route|table|schema|migration|backup|install|rollback|search|service\",\"value\":string}]}. Pour action=modify, N ENVOIE PAS le fichier complet : utilise exclusivement edits[] avec les plus petits remplacements exacts possibles et omets content. Chaque search doit être une chaîne exacte présente UNE SEULE FOIS dans le fichier réel épinglé ; replace contient sa version finale. Pour une méthode PHP, préfère TOUJOURS symbol=\"method:Nom\"/replace. Pour un autre bloc volumineux, préfère start/end/replace : start et end sont deux ancres exactes, courtes et uniques du fichier, et replace contient la zone finale complète, ancres comprises. N’envoie jamais un search gigantesque simplement pour remplacer un gros bloc. evolution reconstruit localement le fichier complet, puis effectue ses validations, son diff et son SHA-256 sans faire payer sa réémission au modèle. Pour action=create seulement, utilise content avec le contenu complet et edits=[]. Tu ne peux demander une lecture tardive QUE si un composant indispensable manque réellement du contexte final. Si tu identifies des fichiers ou tables manquants, retourne obligatoirement status=needs_context avec needs_context structuré : n'écris jamais une section prose 'Étape requise' ou 'Exploration requise' destinée à l'administrateur. Dans ce cas, status=needs_context, files=[], database=null et needs_context doit contenir uniquement des cibles techniques concrètes et structurées. Pour un fichier précis du PROJET CIBLE, utilise type=path avec son chemin exact. Les blocs CROSS PROJECT SOURCE sont déjà lus intégralement et sont strictement en lecture seule : ne redemande pas leur path comme s'il appartenait au projet cible et ne les place jamais dans files[]. Pour une structure de table utilise schema, pour ses migrations migration, et pour les règles du cycle d’exécution backup/install/rollback. Pour un Model CodeIgniter, demande son symbole : evolution fournit automatiquement le modèle complet puis sa table déclarée, son schéma réel, ses index/contraintes et ses migrations. Pour une recherche littérale indispensable dans le code, utilise search avec le terme exact. Pour les services liés aux fichiers/uploads/media, utilise service avec file-management. Si tu émets malgré tout dans summary des lignes structurées comme path:, schema:, migration:, symbol:, search: ou service:, evolution les exécutera comme plan interne et te rappellera automatiquement ; elles ne sont jamais destinées à l'administrateur. Tout bloc \"BEGIN DATABASE SCHEMA <table> | REAL DATABASE\" est une lecture réelle et épinglée de la base connectée : tu ne peux pas déclarer ce schéma absent s'il est présent. N’annonce jamais simplement une prochaine étape. BUDGET SORTIE STRICT : minimise le JSON. summary bref. Pour un fichier existant, ne recopie aucune portion inchangée hors des petites ancres nécessaires. Utilise symbol/replace pour les méthodes PHP, sinon start/end pour éviter les gros search. Si une modification est nécessaire, status DOIT être proposal et files[] et/ou database DOIT être non vide. Pour chaque fichier existant modifié, retourne uniquement des edits exacts minimaux ; ne réémets jamais son contenu complet. Pour un nouveau fichier seulement, fournis content complet. Si réellement aucune modification n’est nécessaire, utilise status=no_change, files=[], database=null et justifie précisément dans summary. Maximum 7 fichiers migration comprise. Chemins autorisés: app/ et public/assets/. Ne touche jamais .env, writable, public/uploads, .git ou vendor. REGLE CANONIQUE MIGRATION BDD : une migration est représentée EXCLUSIVEMENT dans database. Si database != null, database.migration_path contient son chemin et database.content son contenu PHP COMPLET. Le même migration_path NE DOIT JAMAIS apparaître dans files[]. files[] contient uniquement les autres fichiers applicatifs. REGLE CANONIQUE FILES : un même path ne doit apparaître qu’une seule fois dans files[]. Si plusieurs zones d’un fichier doivent changer, regroupe tous leurs edits[] dans cette unique entrée. La migration compte néanmoins dans la limite globale de 7 fichiers. Si une migration est nécessaire, utilise CodeIgniter 4 Forge/Query Builder, namespace App\\Database\\Migrations, extends Migration, up() et down(), sans SQL brut. Nom conseillé: app/Database/Migrations/2026-10-10-232906_NomDescriptif.php. DIAGNOSTIC evolution: Contexte garanti : 2 fichier(s) réellement injecté(s) : app/Views/admin/development/index.php (37062 octets), app/Services/DevelopmentCodeExplorerService.php (83944 octets). DEMANDE RACINE IMMUABLE: DEMANDE RACINE (IMMUTABLE) :\nModifie le titre \"Nexum AI - Développement\" en \"Matrice de développement\" dans la page actuelle IMPORTANT: les tâches d exploration (ZIP, rotation, route, vue, modèle, validation, etc.) sont seulement des vérifications intermédiaires et ne remplacent jamais cette demande racine.\nDEPENDANCES TARDIVES RESOLUES ET VERIFIEES PAR evolution (1/6). Le bloc CONTEXT PAYLOAD INSPECTOR en tête décrit le payload exact de cet appel. Tout fichier marqué BEGIN PINNED ... COMPLET a été relu sur le disque juste avant cet appel. Tu dois finaliser la DEMANDE RACINE et tu ne peux pas déclarer absent un chemin exact déjà marqué COMPLET ou un schéma REAL DATABASE déjà listé OK.",
    "stream": true,
    "prompt_cache_key": "evolution-development-de330c965db3e258acdb73abb0071c940c1e5083",
    "reasoning": {
        "effort": "low"
    },
    "max_output_tokens": 24000,
    "text": {
        "format": {
            "type": "json_object"
        },
        "verbosity": "low"
    }
}