logs & datas into projects/<id>

This commit is contained in:
Nicolas Cantu 2026-03-14 10:18:49 +01:00
parent 5139937d27
commit fa5804779c
23 changed files with 978 additions and 140 deletions

View File

@ -1,6 +1,15 @@
# Gitea issues scripts et agent
# Gitea issues scripts et agents
Dossier dédié au traitement des tickets (issues) Gitea du dépôt **4nk/lecoffre_ng** (https://git.4nkweb.com/4nk/lecoffre_ng/issues). Toute la logique dappel API et Git est dans les scripts ; lagent orchestre et appelle /fix ou /evol.
Dossier dédié au traitement des tickets (issues) Gitea du dépôt **4nk/lecoffre_ng** (https://git.4nkweb.com/4nk/lecoffre_ng/issues). Toute la logique d'appel API et Git est dans les scripts ; les agents orchestrent et appellent /fix ou /evol.
## Agents (.cursor/agents/)
| Agent | Fichier | Rôle |
|-------|---------|------|
| **agent-loop** | `agent-loop.md` | Orchestre la boucle de récupération des mails et le traitement : lance les 2 boucles en arrière-plan (récupération + traitement, pas de timeout) ou exécute x fois (récupération 1 fois puis traitement 1 fois via gitea-issues-process). |
| **gitea-issues-process** | `gitea-issues-process.md` | Traite les issues Gitea et les mails en attente : liste les issues/mails, crée des branches, lance /fix ou /evol, /push-by-script ; workflow mails (fil, réponse réelle, marquage lu). |
Spooler tickets (nouveau) : mails dans **projects/<id>/data/issues/** (filtre par `tickets.authorized_emails` dans conf.json), pas de « non lu », aucun enregistrement supprimé. Format : `gitea-issues/TICKETS_SPOOL_FORMAT.md`. Récupération : `./ia_dev/gitea-issues/tickets-fetch-inbox.sh`. Référence boucle mails (legacy) : `gitea-issues/AGENT_LOOP.md`. Hook Cursor : `sessionStart``.cursor/hooks/remonter-mails.sh` (lit `projects/<id>/data/issues/*.pending`).
## Contexte d'exécution
@ -28,9 +37,12 @@ Dossier dédié au traitement des tickets (issues) Gitea du dépôt **4nk/lecoff
| `mail-send-reply.sh` | `./gitea-issues/mail-send-reply.sh --to <addr> --subject "..." [--body "..." \| stdin] [--in-reply-to "<msg-id>" [--references "..."]]` | Envoie une réponse par mail via le Bridge (SMTP) ; signature « Support IA du projet Lecoffre.io » / ai.support.lecoffreio@4nkweb.com ajoutée automatiquement. |
| `mail-create-issue-from-email.sh` | `./gitea-issues/mail-create-issue-from-email.sh --uid <uid> [--title "..." ] [--body "..."]` | Crée une issue à partir dun mail (UID), optionnel titre/corps formalisés ; marque le mail lu. |
| `mail-mark-read.sh` | `./gitea-issues/mail-mark-read.sh <uid>` | Marque un mail comme lu. |
| `mail-thread-log.sh` | `./gitea-issues/mail-thread-log.sh get-id \| init \| append-sent \| append-issue \| append-commit ...` | **Log par fil** : un fichier par conversation dans `logs/gitea-issues/threads/` (échanges reçus/envoyés, tickets, commits). `get-id --uid <uid>` affiche `THREAD_ID=...` ; `init --uid <uid>` crée/met à jour le fichier ; `append-sent/issue/commit` enregistrent une réponse, une issue ou un commit. |
| `mail-thread-log.sh` | `./gitea-issues/mail-thread-log.sh get-id \| init \| append-sent \| append-issue \| append-commit ...` | **Log par fil** : un fichier par conversation dans `projects/<id>/logs/gitea-issues/threads/` (échanges reçus/envoyés, tickets, commits). `get-id --uid <uid>` affiche `THREAD_ID=...` ; `init --uid <uid>` crée/met à jour le fichier ; `append-sent/issue/commit` enregistrent une réponse, une issue ou un commit. |
| `mail-to-issue.sh` | `./gitea-issues/mail-to-issue.sh` | **Batch** : crée une issue par mail non lu (titre = sujet, corps = texte + From), marque lus. À éviter si on suit le workflow agent (voir cidessous). |
| `agent-loop.sh` | `./gitea-issues/agent-loop.sh [interval_sec]` | **Boucle de surveillance** : exécute périodiquement `mail-list-unread.sh`, met à jour un fichier témoin (`logs/gitea-issues/agent-loop.status`) pour indiquer si la boucle est active, et écrit les mails en attente dans `agent-loop.pending`. Voir `gitea-issues/AGENT_LOOP.md`. |
| `agent-loop.sh` | `./ia_dev/gitea-issues/agent-loop.sh [interval_sec]` | **Boucle de surveillance** : exécute périodiquement `mail-list-unread.sh`, met à jour un fichier témoin (`projects/<id>/logs/gitea-issues/agent-loop.status`) et écrit les mails en attente dans `agent-loop.pending`. Voir `gitea-issues/AGENT_LOOP.md`. |
| `agent-loop-treatment.sh` | `./ia_dev/gitea-issues/agent-loop-treatment.sh` | **Boucle traitement** : vérifie périodiquement `agent-loop.pending` ; si non vide, lance l'agent Cursor (workflow gitea-issues-process) pour traiter les mails. À lancer en arrière-plan (pas de timeout). |
| `agent-loop-retrieval-once.sh` | `./ia_dev/gitea-issues/agent-loop-retrieval-once.sh` | **Récupération une fois** (legacy, basé non lu) : exécute `mail-list-unread.sh` et écrit dans `agent-loop.pending`. Utilisé par l'agent agent-loop pour les cycles « x fois ». |
| `tickets-fetch-inbox.sh` | `./ia_dev/gitea-issues/tickets-fetch-inbox.sh` | **Récupération par expéditeurs autorisés** : filtre `tickets.authorized_emails` (conf.json), pas de marquage lu/non lu. Écrit les nouveaux mails dans `projects/<id>/data/issues/<date>_<from>_<uid>.pending` (JSON). Voir `TICKETS_SPOOL_FORMAT.md`. |
Variables optionnelles : `GITEA_API_URL`, `GITEA_REPO_OWNER`, `GITEA_REPO_NAME`, `GITEA_ISSUES_DIR`.
@ -39,8 +51,8 @@ Variables optionnelles : `GITEA_API_URL`, `GITEA_REPO_OWNER`, `GITEA_REPO_NAME`,
**Ne pas enchaîner directement** : lagent doit dabord lire les non lus, formaliser lissue ou répondre par mail, et ne créer/traiter quau moment où la demande est prête.
1. **Lire les non lus** : `./gitea-issues/mail-list-unread.sh` (ne marque pas les mails comme lus).
2. **Pour chaque mail** : consulter **tout l'historique du fil** avec `./gitea-issues/mail-get-thread.sh <uid>`, créer/mettre à jour le **log du fil** avec `./gitea-issues/mail-thread-log.sh init --uid <uid>` (sortie `THREAD_ID=...` à conserver), puis décider soit denvoyer une réponse directe (demande dinfos) via `mail-send-reply.sh`, soit de formaliser et créer lissue avec `mail-create-issue-from-email.sh` (optionnel `--title` / `--body` formalisés). Si la demande est une correction/évolution prête : créer lissue, traiter (fix/evol), commenter lissue, répondre au mail via `mail-send-reply.sh` (avec `--in-reply-to` pour le fil).
3. **Réponses aux mails** : toujours via le Bridge avec `mail-send-reply.sh`. Chaque envoi est enregistré dans le log du fil avec `mail-thread-log.sh append-sent`.
2. **Pour chaque mail** : consulter **tout l'historique du fil** avec `./gitea-issues/mail-get-thread.sh <uid>`, créer/mettre à jour le **log du fil** avec `./gitea-issues/mail-thread-log.sh init --uid <uid>` (sortie `THREAD_ID=...` à conserver), puis décider soit denvoyer une réponse directe (demande dinfos) via `mail-send-reply.sh`, soit de formaliser et créer lissue avec `mail-create-issue-from-email.sh` (optionnel `--title` / `--body` formalisés). Si la demande est une correction/évolution prête : créer lissue, traiter (fix/evol), commenter lissue, répondre au mail via `mail-send-reply.sh` (avec `--in-reply-to` pour le fil). **Le corps de la réponse** doit contenir la **réponse réelle** à la question (ex. si le mail demande « Décrit les rôles », le body = une description des rôles), jamais le sujet du mail ni la question reçue.
3. **Réponses aux mails** : toujours via le Bridge avec `mail-send-reply.sh`. Le `--body` doit être la **réponse réelle** rédigée par l'agent (contenu de la réponse à la demande), pas le sujet du mail, pas la question reçue, pas un message précédent du fil. Chaque envoi est enregistré dans le log du fil avec `mail-thread-log.sh append-sent --thread-id <id> --to <addr> --subject "..." --body "..."` pour tracer l'expéditeur, le titre et le corps de la réponse envoyée.
**Prérequis :**
@ -97,9 +109,10 @@ Le répertoire `docs/` n'est pas versionné. Pour disposer d'une copie locale (
La connaissance du projet peut reposer **uniquement sur le wiki** (sans lire `docs/`) : les agents peuvent exécuter `./gitea-issues/wiki-get-page.sh <PageName>` pour récupérer le contenu markdown dune page et lutiliser comme référence. Exemples : `./gitea-issues/wiki-get-page.sh Home`, `./gitea-issues/wiki-get-page.sh Operations`, `./gitea-issues/wiki-get-page.sh Code-Standards`. Prérequis : token Gitea (comme pour les autres scripts wiki). Les agents peuvent ainsi consulter la doc projet à la demande depuis le wiki, sans dépendre des fichiers locaux `docs/`.
## Agent
## Agents (commandes)
Commande **/gitea-issues-process** (agent `.cursor/agents/gitea-issues-process.md`) : traite un ou plusieurs tickets en sappuyant uniquement sur ces scripts, puis appelle /fix ou /evol et /push-by-script. Voir le fichier de lagent pour le workflow exact.
- **/agent-loop** (`agent-loop.md`) : lance les 2 boucles (récupération + traitement) en arrière-plan ou exécute x cycles (récupération 1 fois puis traitement 1 fois). À la fin d'une boucle de récupération, la boucle traitement lance gitea-issues-process sur les mails reçus.
- **/gitea-issues-process** (`gitea-issues-process.md`) : traite les issues Gitea et les mails en attente (workflow script au maximum, /fix ou /evol, /push-by-script). Voir le fichier de l'agent pour le workflow exact.
## Référence

View File

@ -0,0 +1,77 @@
---
name: agent-loop
description: Orchestre la boucle de récupération des mails et le traitement par gitea-issues-process. Peut lancer 2 boucles en arrière-plan (récupération + traitement, pas de timeout) ou exécuter x fois (récupération 1 fois puis traitement 1 fois).
model: inherit
is_background: false
---
# Agent agent-loop
**Contexte projet :** La configuration et la documentation du projet sont dans `projects/<id>/` ; `<id>` = contenu du fichier `../ai_project_id` (à la racine du dépôt projet, parent de ia_dev). Racine du dépôt projet = `/home/desk/code/lecoffre_ng_test` (ou `..` depuis le workspace ia_dev). Rappeler en début d'exécution : **projet** = contenu de `../ai_project_id`, **branche** = `git -C .. branch --show-current`, **répertoire de travail** = répertoire du dépôt dans `../`.
**Horodatage** : au début et à la fin d'exécution, afficher date/heure, projet, branche, répertoire de travail du dépôt dans `../`.
Tu es l'agent qui **orchestre** la surveillance des mails et leur traitement. Tu ne traites pas les mails toimême : le traitement (réponse, issues, marquage lu) est fait par l'**agent gitea-issues-process**. Tu lances les scripts et/ou les sous-agents selon la demande.
**Références obligatoires** : lire `gitea-issues/AGENT_LOOP.md` (fichier témoin, variables, boucles) et `gitea-issues/README.md` (contexte d'exécution). Tous les scripts sont invoqués depuis la **racine du dépôt projet** : `cd <racine_projet> && ./ia_dev/gitea-issues/<script>.sh` (depuis le workspace ia_dev : `cd .. && ./ia_dev/gitea-issues/<script>.sh`).
---
## 1. Lancer les 2 boucles en arrière-plan (pas de timeout)
Si l'utilisateur demande de **lancer les 2 boucles en arrière-plan** (récupération des mails + traitement des mails, sans timeout) :
1. **Boucle récupération** : lancer en arrière-plan depuis la racine du dépôt projet (parent de ia_dev) :
```bash
cd .. && nohup ./ia_dev/gitea-issues/agent-loop.sh 60 >> ia_dev/projects/<id>/logs/gitea-issues/agent-loop.log 2>&1 &
```
(Depuis le workspace ia_dev : `cd ..` = racine projet. Les logs et pending sont sous `ia_dev/projects/<id>/logs/gitea-issues/`.) Cette boucle exécute périodiquement `mail-list-unread.sh`, met à jour le fichier témoin et écrit les mails en attente dans `agent-loop.pending`.
2. **Boucle traitement** : lancer en arrière-plan depuis la racine du dépôt projet :
```bash
cd .. && nohup ./ia_dev/gitea-issues/agent-loop-treatment.sh >> ia_dev/projects/<id>/logs/gitea-issues/agent-loop-treatment.log 2>&1 &
```
Cette boucle vérifie périodiquement si `agent-loop.pending` est non vide ; si oui, elle lance l'agent **gitea-issues-process** (via Cursor Agent CLI) pour traiter les mails conformément au workflow mails, puis attend avant la prochaine vérification.
Ne pas masquer les sorties des scripts ; indiquer les PID des processus lancés et où consulter les logs (`ia_dev/projects/<id>/logs/gitea-issues/agent-loop.log`, `agent-loop-treatment.log`).
**À la fin d'une boucle (récupération)** : le script `agent-loop.sh` met à jour le fichier pending ; la **boucle traitement** (`agent-loop-treatment.sh`) détecte les mails et lance l'agent gitea-issues-process sur le contenu reçu. Aucune action supplémentaire de ta part n'est requise une fois les deux boucles lancées.
---
## 2. Lancer x fois : récupération 1 fois puis traitement 1 fois (x cycles)
Si l'utilisateur demande de **lancer x fois** les deux sous-agents (une récupération puis un traitement, répété x fois) :
Pour chaque cycle `i` de 1 à x :
1. **Récupération une fois** : exécuter depuis la racine du dépôt projet (depuis ia_dev : `cd ..`) :
```bash
cd .. && ./ia_dev/gitea-issues/agent-loop-retrieval-once.sh
```
Ce script exécute `mail-list-unread.sh` et écrit la sortie dans `projects/<id>/logs/gitea-issues/agent-loop.pending` (et met à jour le fichier témoin). Pas d'arrière-plan : attendre la fin du script.
2. **Traitement une fois** : lancer **intégralement** l'agent **gitea-issues-process** sur les mails reçus (contenu de `projects/<id>/logs/gitea-issues/agent-loop.pending`). Utiliser le sous-agent Cursor (mcp_task ou équivalent) avec le type `gitea-issues-process` et un prompt du type :
« Traite les mails listés dans `projects/<id>/logs/gitea-issues/agent-loop.pending` (contenu ci-dessous ou à lire depuis le fichier). Suivre strictement le workflow mails de l'agent gitea-issues-process : pour chaque UID listé, exécuter mail-get-thread.sh, mail-thread-log.sh init, décider réponse/issue, rédiger la réponse réelle (--body = texte composé par toi, jamais une citation du fil), mail-send-reply.sh, mail-thread-log.sh append-sent, mail-mark-read.sh. »
Répéter les étapes 1 et 2 pour les x cycles demandés. Chaque cycle traite **une seule** récupération (ce qui a été reçu lors de cette récupération).
---
## 3. Autres demandes
- **Boucle limitée depuis le chat (N itérations, attente 1 min entre chaque)** : exécuter `cd .. && ./ia_dev/gitea-issues/agent-loop-chat-iterations.sh [N] [--repeat]` (sans arrière-plan, pour éviter timeout). Par défaut N=3 ; `--repeat` pour relancer après N itérations.
- **Consulter les mails en attente** : lire le fichier `ia_dev/projects/<id>/logs/gitea-issues/agent-loop.pending` (depuis la racine du dépôt) ou inviter l'utilisateur à lancer l'agent gitea-issues-process avec ce fichier en entrée.
- **Vérifier si la boucle est active** : le fichier témoin est `ia_dev/projects/<id>/logs/gitea-issues/agent-loop.status` ; s'il a été modifié depuis moins de 2 × intervalle (ex. 120 s si intervalle 60 s), la boucle est considérée active.
---
## Contraintes
- Ne pas déclencher la CI, ne pas écrire en base, ne pas masquer les sorties des scripts.
- Répertoire d'exécution des scripts : toujours **racine du dépôt projet** (`/home/desk/code/lecoffre_ng_test` ou `cd ..` depuis ia_dev).
- Le traitement des mails (réponse réelle, workflow fil, marquage lu) est **uniquement** assuré par l'agent gitea-issues-process ; ne pas court-circuiter son workflow.
## Clôture
Appliquer **intégralement** `.cursor/rules/cloture-evolution.mdc` en fin de réponse (horodatage, projet, branche, répertoire, points 1 à 19).

View File

@ -7,9 +7,13 @@ is_background: false
# Agent gitea-issues-process
Lis :
gitea-issues/README.md
gitea-issues/AGENT_LOOP.md
**Contexte projet :** La configuration et la documentation du projet sont dans `projects/<id>/` ; `<id>` = contenu du fichier `../ai_project_id` (à la racine du dépôt projet, parent de ia_dev). Rappeler en début d'exécution : projet = contenu de `../ai_project_id`, config = `ia_dev/projects/<id>/`. Même schéma pour tout projet utilisant ia_dev ; ne pas hardcoder de chemin projet.
Tu es l'agent qui traite les **tickets (issues) Gitea** du dépôt du projet courant. Le dépôt et l'URL Gitea (ticketing, wiki) sont définis dans `projects/<slug>.json` (clé `git.ticketing_url`, `git.wiki_url`) ; le slug projet est donné par `.ia_project` à la racine du repo ou par la variable d'environnement `IA_PROJECT`. Toute la logique d'appel API et Git doit passer par les **scripts** du dossier `gitea-issues/` ; l'agent ne fait pas d'appels curl ou git directs pour ces opérations.
Tu es l'agent qui traite les **tickets (issues) Gitea** du dépôt du projet courant. Le dépôt et l'URL Gitea (ticketing, wiki) sont définis dans `projects/<slug>/conf.json` (clé `tickets.ticketing_url`, `git.wiki_url`) ; le slug projet est donné par `.ia_project` ou `ai_project_id` à la racine du repo ou par la variable d'environnement `IA_PROJECT`. Toute la logique d'appel API et Git doit passer par les **scripts** du dossier `gitea-issues/` ; l'agent ne fait pas d'appels curl ou git directs pour ces opérations.
**Horodatage et contexte** : au début et à la fin d'exécution, afficher date/heure, **projet** (contenu de `../ai_project_id`), **branche** et **répertoire de travail** du dépôt dans `../` (pas ceux de `ia_dev`).
@ -41,7 +45,25 @@ Tu es l'agent qui traite les **tickets (issues) Gitea** du dépôt du projet cou
3. **Boucle** : répéter l'étape 2 pour chaque issue de la liste (ou une seule si numéro fourni). Ne pas traiter en parallèle : une issue après l'autre.
**Boucle récupération mails (depuis le chat)** : si l'utilisateur demande « Lance la boucle récupération emails, attend 1 min et relance, N itérations », exécuter depuis la racine du dépôt projet : `cd <racine_projet> && ./ia_dev/gitea-issues/agent-loop-chat-iterations.sh [N]` (depuis workspace ia_dev : `cd .. && ./ia_dev/gitea-issues/agent-loop-chat-iterations.sh [N]`). Pour N élevé (ex. 300), indiquer de lancer le script en terminal (tmux/screen) depuis la même racine. Au lancement le script envoie un mail de test à nicolas.cantu@pm.me ; les mails en attente sont dans `logs/gitea-issues/agent-loop.pending`. Traiter les mails selon le workflow (mail-get-thread, mail-thread-log, réponse ou issue, mail-mark-read). Voir `gitea-issues/AGENT_LOOP.md`.
## Workflow mails (interaction agent ↔ scripts)
L'agent **ne fait pas** d'appels IMAP/SMTP ni de curl Gitea directs : il **invoque uniquement** les scripts `gitea-issues/*.sh` depuis la racine du dépôt projet. Les scripts lisent la config (`.secrets` sous ia_dev) et font les appels réels.
**Ordre pour traiter les mails en attente** (source : **projects/<id>/data/issues/*.pending** (spooler JSON, voir `gitea-issues/TICKETS_SPOOL_FORMAT.md`) ou `projects/<id>/logs/gitea-issues/agent-loop.pending` en legacy, ou demande utilisateur) : se baser uniquement sur le **statut des issues** pour le traitement ; **aucun enregistrement ne doit être supprimé**.
1. **Lister les non lus** : exécuter `./ia_dev/gitea-issues/mail-list-unread.sh`. Sortie = blocs par mail (UID, From, To, Subject, Date, Body). Lecture seule ; ne marque pas comme lu.
2. **Pour chaque mail (UID)** :
- **Récupérer le fil** : `./ia_dev/gitea-issues/mail-get-thread.sh <uid>`. Donne tout l'historique du fil (References/In-Reply-To) pour décider en connaissance de cause.
- **Initialiser le log du fil** : `./ia_dev/gitea-issues/mail-thread-log.sh init --uid <uid>`. Sortie `THREAD_ID=...` à conserver pour append-sent.
- **Décider** : soit réponse directe (demande d'infos), soit créer une issue (`mail-create-issue-from-email.sh`), soit les deux (évolution/correctif → issue + fix/evol puis réponse au mail).
- **Si réponse mail** : rédiger la **réponse réelle** (pas le sujet ni la question reçue), puis `./ia_dev/gitea-issues/mail-send-reply.sh --to <addr> --subject "Re: ..." --body "<ta réponse>" --in-reply-to "<Message-ID du mail reçu>"`. Puis `./ia_dev/gitea-issues/mail-thread-log.sh append-sent --thread-id <THREAD_ID> --to <addr> --subject "..." --body "<ta réponse>"` pour tracer l'envoi.
- **Marquer lu** : `./ia_dev/gitea-issues/mail-mark-read.sh <uid>`.
**Réponses mail (obligatoire)** : le `--body` de `mail-send-reply.sh` doit contenir uniquement la **réponse que tu rédiges** à la question du mail (ex. « Décrit les rôles » → body = description des rôles). Jamais le sujet du mail, la question reçue ou un message précédent du fil.
**Récupération mails (spooler data/issues)** : exécuter `cd <racine_projet> && ./ia_dev/gitea-issues/tickets-fetch-inbox.sh` pour récupérer les mails filtrés par `tickets.authorized_emails` (conf.json) et les écrire en `projects/<id>/data/issues/*.pending` (JSON). Pas de marquage lu/non lu. **Boucle legacy (non lu)** : si l'utilisateur demande « Lance la boucle récupération emails… N itérations », exécuter `./ia_dev/gitea-issues/agent-loop-chat-iterations.sh [N] [--repeat]`. Mails en attente : **projects/<id>/data/issues/*.pending** (prioritaire) ou `projects/<id>/logs/gitea-issues/agent-loop.pending` ; les traiter selon le workflow ci-dessus.
**Pièces jointes (spooler data/issues)** : chaque fichier `projects/<id>/data/issues/*.pending` est un JSON pouvant contenir un tableau `attachments` (champs `filename`, `path`, `content_type`, `size`). Les fichiers sont stockés sous `projects/<id>/data/issues/<base>.d/` ; `path` est relatif à `data/issues/`. Pour utiliser une pièce jointe, lire le fichier à `ia_dev/projects/<id>/data/issues/<path>` (ex. `projects/<id>/data/issues/2026-03-14T094530_laurence_lecoffre.io_42.d/0_document.pdf`). Les utiliser pour traiter le ticket (analyse, création dissue avec référence au fichier, etc.) sans les supprimer.
## Contraintes

10
.cursor/hooks.json Normal file
View File

@ -0,0 +1,10 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"command": ".cursor/hooks/remonter-mails.sh"
}
]
}
}

42
.cursor/hooks/remonter-mails.sh Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Hook script: read projects/<id>/data/issues/*.pending (spooler tickets) and output JSON with additional_context for sessionStart.
# Run from project root (Cursor). Repo root = parent of ia_dev when script lives in ia_dev/.cursor/hooks.
# Output: {"additional_context": "Mails en attente (data/issues):\n<content>"} or {"additional_context": ""} if none.
set -euo pipefail
# Consume stdin (hook input JSON)
cat > /dev/null
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
IA_DEV="${ROOT}/ia_dev"
PROJECT_SLUG=""
[ -f "${ROOT}/ai_project_id" ] && PROJECT_SLUG="$(cat "${ROOT}/ai_project_id" | sed 's/[[:space:]]//g')"
[ -z "$PROJECT_SLUG" ] && [ -f "${ROOT}/.ia_project" ] && PROJECT_SLUG="$(cat "${ROOT}/.ia_project" | sed 's/[[:space:]]//g')"
if [ -n "$PROJECT_SLUG" ] && [ -d "${IA_DEV}/projects/${PROJECT_SLUG}" ]; then
SPOOL="${IA_DEV}/projects/${PROJECT_SLUG}/data/issues"
else
SPOOL="${ROOT}/data/issues"
fi
if [ ! -d "$SPOOL" ]; then
printf '%s\n' "{\"additional_context\": \"\"}"
exit 0
fi
CONTENT=""
for f in "${SPOOL}"/*.pending; do
[ -f "$f" ] && CONTENT="${CONTENT}--- ${f##*/} ---"$'\n'"$(cat "$f")"$'\n'
done
if [ -n "$CONTENT" ]; then
if command -v jq >/dev/null 2>&1; then
printf '%s' "$CONTENT" | jq -R -s '{additional_context: ("Mails en attente (data/issues):\n" + .)}'
else
ESCAPED="${CONTENT//\\/\\\\}"
ESCAPED="${ESCAPED//\"/\\\"}"
ESCAPED="${ESCAPED//$'\n'/\\n}"
printf '%s\n' "{\"additional_context\": \"Mails en attente (data/issues):\\n${ESCAPED}\"}"
fi
else
printf '%s\n' "{\"additional_context\": \"\"}"
fi

View File

@ -16,7 +16,7 @@ Voir `projects/README.md` pour le schéma de configuration et les exemples.
Les scripts sont invoqués depuis la **racine du dépôt hôte**. Ils s'y placent (ou s'y ré-exécutent) avant de continuer.
- **deploy/** : `PROJECT_ROOT` = git toplevel ; ré-exécution depuis la racine si besoin. Chemin du script résolu (`readlink -f` / `realpath`) pour que `IA_DEV_ROOT` soit correct même si `deploy` est un symlink vers `ia_dev/deploy`.
- **gitea-issues/** : `ROOT` = git toplevel (sinon parent de `GITEA_ISSUES_DIR`) ; `cd "$ROOT"` et `export REPO_ROOT` pour que les scripts Python utilisent la racine hôte pour `.secrets/` et `logs/` (y compris quand gitea-issues est dans `ia_dev/gitea-issues`).
- **gitea-issues/** : `ROOT` = racine du dépôt projet (parent de ia_dev) ; `REPO_ROOT` idem. `.secrets` reste sous ia_dev (`./.secrets`) ; **logs** et **data** (spooler tickets) sont par projet sous `projects/<id>/logs/` et `projects/<id>/data/issues/` (id = slug, ex. `ai_project_id`).
## Scripts centralisés (submodule)

View File

@ -1,108 +1,103 @@
# Boucle agent (agent-loop) surveillance des mails et fichier témoin
Script qui tourne en boucle dans lenvironnement Cursor de ce projet pour surveiller les mails non lus et maintenir un **fichier témoin** indiquant si la boucle est active.
Script qui tourne en boucle dans l'environnement Cursor de ce projet pour surveiller les mails non lus et maintenir un **fichier témoin** indiquant si la boucle est active.
**Agent orchestrateur** : `.cursor/agents/agent-loop.md` (lance les boucles, x fois récupération + traitement).
## Rôle du script
- Exécuter périodiquement `mail-list-unread.sh` (sans modifier létat des mails).
- Exécuter périodiquement `mail-list-unread.sh` (sans modifier l'état des mails).
- Mettre à jour à chaque tour un **fichier témoin** (statut + horodatage) pour savoir si la boucle est active.
- Quand des mails non lus sont détectés, écrire un fichier **pending** et afficher un message invitant à lancer lagent dans Cursor.
- Quand des mails non lus sont détectés, écrire un fichier **pending** et afficher un message invitant à lancer l'agent dans Cursor.
Le script **ne traite pas** les mails luimême : le traitement (réponse, issues, commits) est fait par l**agent gitea-issues-process**. Vous pouvez soit lancer lagent à la main dans Cursor, soit faire lancer lagent par la boucle en activant `AGENT_LOOP_RUN_AGENT=1` (voir cidessous) si la **Cursor Agent CLI** est installée.
Le script **ne traite pas** les mails luimême : le traitement (réponse, issues, commits) est fait par l'**agent gitea-issues-process**. Vous pouvez soit lancer l'agent à la main dans Cursor, soit faire lancer l'agent par la boucle en activant `AGENT_LOOP_RUN_AGENT=1` (voir cidessous) si la **Cursor Agent CLI** est installée.
## Environnement au démarrage
Au démarrage, le script **source systématiquement** `~/.bashrc` (si le fichier existe et est lisible), puis ajoute `~/.local/bin` au `PATH` si ce répertoire existe. Ainsi, la commande `agent` (Cursor Agent CLI) est trouvée même si la boucle est lancée depuis un contexte où le shell na pas chargé le profil (nohup, cron, etc.).
Au démarrage, le script **source systématiquement** `~/.bashrc` (si le fichier existe et est lisible), puis ajoute `~/.local/bin` au `PATH` si ce répertoire existe. Ainsi, la commande `agent` (Cursor Agent CLI) est trouvée même si la boucle est lancée depuis un contexte où le shell n'a pas chargé le profil (nohup, cron, etc.).
## Lancement
Si le fichier `.secrets/gitea-issues/agent-loop.env` existe, il est sourcé au démarrage (voir `agent-loop.env.example`).
Depuis la **racine du dépôt** :
Depuis la **racine du dépôt projet** (parent de ia_dev) :
```bash
./gitea-issues/agent-loop.sh
cd <racine_projet> && ./ia_dev/gitea-issues/agent-loop.sh [interval_sec]
```
Avec un intervalle en secondes (défaut 60) :
```bash
./gitea-issues/agent-loop.sh 120
./ia_dev/gitea-issues/agent-loop.sh 120
```
Ou via une variable denvironnement :
Ou via une variable d'environnement :
```bash
AGENT_LOOP_INTERVAL_SEC=120 ./gitea-issues/agent-loop.sh
AGENT_LOOP_INTERVAL_SEC=120 ./ia_dev/gitea-issues/agent-loop.sh
```
Pour lexécuter en arrière-plan et garder la boucle active après fermeture du terminal, utiliser `nohup` ou un gestionnaire de processus (systemd, screen, tmux) :
Pour l'exécuter en arrière-plan et garder la boucle active après fermeture du terminal, utiliser `nohup` ou un gestionnaire de processus (systemd, screen, tmux) :
```bash
nohup ./gitea-issues/agent-loop.sh 60 >> logs/gitea-issues/agent-loop.log 2>&1 &
nohup ./ia_dev/gitea-issues/agent-loop.sh 60 >> ia_dev/projects/<id>/logs/gitea-issues/agent-loop.log 2>&1 &
```
## Fichier témoin (actif / inactif)
- **Emplacement** : `logs/gitea-issues/agent-loop.status` (ou `AGENT_LOOP_STATUS_FILE` si défini).
- **Emplacement** : `projects/<id>/logs/gitea-issues/agent-loop.status` (ou `AGENT_LOOP_STATUS_FILE` si défini).
- **Contenu** : trois lignes
1. Horodatage ISO 8601 du dernier tour.
2. Statut : `idle` | `mails_pending` | `running` | `error`.
3. Détail optionnel (ex. message pour lutilisateur).
3. Détail optionnel (ex. message pour l'utilisateur).
**Considérer la boucle comme active** si le fichier a été modifié depuis moins de **2 × intervalle** (ex. moins de 120 s si intervalle = 60 s). Au-delà, la boucle est considérée arrêtée.
Exemple de vérification (intervalle 60 s) :
```bash
# Fichier modifié il y a moins de 120 s ?
[ $(($(date +%s) - $(stat -c %Y logs/gitea-issues/agent-loop.status 2>/dev/null || 0))) -lt 120 ] && echo "Actif" || echo "Inactif"
```
Ou simplement consulter la première ligne du fichier (date du dernier tour) et la comparer à lheure courante.
## Fichier pending (mails en attente)
- **Emplacement** : `logs/gitea-issues/agent-loop.pending`.
- **Rôle** : quand des mails non lus sont détectés, le script y écrit un bloc (horodatage, statut `mails_pending`, puis la sortie de `mail-list-unread.sh`). Permet de voir quels mails attendent un traitement par lagent.
- Quand il ny a plus de non lus, le script vide ce fichier au tour suivant.
- **Emplacement** : `projects/<id>/logs/gitea-issues/agent-loop.pending`.
- **Rôle** : quand des mails non lus sont détectés, le script y écrit un bloc (horodatage, statut `mails_pending`, puis la sortie de `mail-list-unread.sh`). Permet de voir quels mails attendent un traitement par l'agent.
- Quand il n'y a plus de non lus, le script vide ce fichier au tour suivant.
## Variables denvironnement
**Hook Cursor** : un hook (`.cursor/hooks.json``sessionStart` / `beforeSubmitPrompt`) peut exécuter un script qui lit ce fichier pour « remonter » les mails reçus au contexte de l'agent (voir section Référence hooks).
## Variables d'environnement
Variables possibles dans `.secrets/gitea-issues/agent-loop.env` ou en export shell :
| Variable | Défaut | Description |
|----------|--------|-------------|
| `AGENT_LOOP_INTERVAL_SEC` | 60 | Intervalle entre deux vérifications (secondes). Peut aussi être passé en premier argument au script. |
| `AGENT_LOOP_RUN_AGENT` | 0 | Si mis à `1`, la boucle lance la **Cursor Agent CLI** (`agent`) quand des mails non lus sont détectés, avec un prompt qui exécute le workflow mails de gitea-issues-process. Nécessite que la commande `agent` soit installée (voir [Cursor CLI](https://cursor.com/docs/cli/using)). Si `agent` nest pas dans le PATH, la boucle se contente de mettre à jour le statut et le fichier pending. |
| `AGENT_LOOP_RUN_AGENT` | 0 | Si mis à `1`, la boucle lance la **Cursor Agent CLI** (`agent`) quand des mails non lus sont détectés, avec un prompt qui exécute le workflow mails de gitea-issues-process. Nécessite que la commande `agent` soit installée (voir [Cursor CLI](https://cursor.com/docs/cli/using)). Si `agent` n'est pas dans le PATH, la boucle se contente de mettre à jour le statut et le fichier pending. |
| `AGENT_LOOP_MODEL` | `sonnet-4.6` | Modèle utilisé par la CLI (`agent --model ...`). Par défaut `sonnet-4.6` pour limiter les blocages liés aux quotas Opus. Ex. : `AGENT_LOOP_MODEL=gpt-5.4-low` ; liste : `agent models`. |
| `AGENT_LOOP_STATUS_FILE` | `logs/gitea-issues/agent-loop.status` | Chemin du fichier témoin. |
| `AGENT_LOOP_PENDING_FILE` | `logs/gitea-issues/agent-loop.pending` | Chemin du fichier pending. |
| `GITEA_ISSUES_DIR` | répertoire du script | Racine des scripts gitea-issues (pour appeler `mail-list-unread.sh`). |
| `AGENT_LOOP_STATUS_FILE` | `projects/<id>/logs/gitea-issues/agent-loop.status` | Chemin du fichier témoin (sous ia_dev, par projet). |
| `AGENT_LOOP_PENDING_FILE` | `projects/<id>/logs/gitea-issues/agent-loop.pending` | Chemin du fichier pending (sous ia_dev, par projet). |
| `GITEA_ISSUES_DIR` | répertoire du script | Racine des scripts gitea-issues (pour appeler les scripts mail). |
## Traiter les mails
Dès que le statut est `mails_pending` ou que des mails apparaissent dans `agent-loop.pending` :
1. **Option A (manuel)** : ouvrir le projet dans **Cursor**, lancer l**agent gitea-issues-process** (commande `/gitea-issues-process` ou via linterface des agents).
2. **Option B (automatique)** : lancer la boucle avec `AGENT_LOOP_RUN_AGENT=1` et la **Cursor Agent CLI** installée (`agent` dans le PATH). Lorsque des mails non lus sont détectés, le script invoque `agent -p "..." -f` pour exécuter le workflow mails (fil, log, réponse, marquage lu). Voir [Cursor CLI](https://cursor.com/docs/cli/using) pour linstallation.
1. **Option A (manuel)** : ouvrir le projet dans **Cursor**, lancer l'**agent gitea-issues-process** (commande `/gitea-issues-process` ou via l'interface des agents).
2. **Option B (automatique)** : lancer la boucle avec `AGENT_LOOP_RUN_AGENT=1` et la **Cursor Agent CLI** installée (`agent` dans le PATH). Lorsque des mails non lus sont détectés, le script invoque `agent -p "..." -f` pour exécuter le workflow mails (fil, log, réponse, marquage lu). Voir [Cursor CLI](https://cursor.com/docs/cli/using) pour l'installation.
Lagent lit les non lus, consulte les fils, répond par mail, crée des issues si besoin, et marque les mails comme lus.
L'agent lit les non lus, consulte les fils, répond par mail, crée des issues si besoin, et marque les mails comme lus. **Important** : le corps de chaque réponse doit contenir la **réponse réelle** à la question du mail (ex. si on demande « Décrit les rôles », envoyer une description des rôles), jamais le sujet du mail, la question reçue ou un message précédent du fil ; uniquement le contenu rédigé par l'agent en réponse à la demande.
Après passage de lagent, au prochain tour de la boucle le statut repassera à `idle` et le fichier pending sera vidé (sil ny a plus de non lus).
Après passage de l'agent, au prochain tour de la boucle le statut repassera à `idle` et le fichier pending sera vidé (s'il n'y a plus de non lus).
## Logs
Le script nécrit pas de log structuré par défaut. Pour garder une trace des tours et des messages affichés :
Le script n'écrit pas de log structuré par défaut. Pour garder une trace des tours et des messages affichés :
```bash
./gitea-issues/agent-loop.sh 60 2>&1 | tee -a logs/gitea-issues/agent-loop.log
./ia_dev/gitea-issues/agent-loop.sh 60 2>&1 | tee -a ia_dev/projects/<id>/logs/gitea-issues/agent-loop.log
```
Ou en arrière-plan :
```bash
nohup ./gitea-issues/agent-loop.sh 60 >> logs/gitea-issues/agent-loop.log 2>&1 &
nohup ./ia_dev/gitea-issues/agent-loop.sh 60 >> ia_dev/projects/<id>/logs/gitea-issues/agent-loop.log 2>&1 &
```
## Arrêter la boucle
@ -114,13 +109,30 @@ Après arrêt, le fichier témoin ne sera plus mis à jour ; après 2 × interva
## Boucle limitée depuis le chat (sans API payante)
Pour lancer depuis **ce** chat (instance Cursor ouverte, abonnement Ultra) une boucle limitée : récupération des mails → attente 1 min → récupération → … sans consommer de crédits API ni lancer la CLI `agent` :
Pour lancer depuis le chat Cursor une boucle limitée : récupération des mails → attente 1 min → récupération → … sans consommer de crédits API ni lancer la CLI `agent` :
1. Demander dans le chat : *« Lance la boucle récupération emails : subagent puis attend 1 min et relance, N itérations »* (ou similaire).
2. Lagent exécute `cd /home/desk/code/lecoffre_ng_test && ./ia_dev/gitea-issues/agent-loop-chat-iterations.sh [N]` depuis la racine du dépôt projet. Par défaut N=3 ; pour 300 itérations, lancer en terminal (tmux/screen) depuis la même racine.
1. Demander dans le chat : *« Lance la boucle récupération emails : N itérations »* (ou similaire).
2. L'agent exécute `cd <racine_projet> && ./ia_dev/gitea-issues/agent-loop-chat-iterations.sh [N] [--repeat]`. Par défaut N=3 ; `--repeat` = à la fin des N itérations, relancer. Pour 600 itérations avec relance : `... 600 --repeat`.
3. Au lancement, le script envoie un mail de test à nicolas.cantu@pm.me pour vérifier SMTP ; si l'envoi échoue, le script s'arrête (code 1).
4. Chaque itération exécute `mail-list-unread.sh` puis attend 60 secondes. Les mails en attente sont écrits dans `logs/gitea-issues/agent-loop.pending` ; traiter ensuite dans le même chat (workflow gitea-issues-process, voir `.cursor/agents/gitea-issues-process.md`).
4. Chaque itération exécute `mail-list-unread.sh` puis attend 60 secondes. **Log** : expéditeur (From), titres (Subject) et sortie sont écrits dans `projects/<id>/logs/gitea-issues/agent-loop-chat-iterations.log`. Les mails en attente sont dans `projects/<id>/logs/gitea-issues/agent-loop.pending`. Lors de l'envoi d'une réponse, toujours appeler `mail-thread-log.sh append-sent` avec `--body` pour logger la réponse.
**Pourquoi l'envoi pouvait échouer** : si le script était invoqué depuis la racine projet avec ROOT = toplevel Git (racine projet), le chemin `./gitea-issues/` n'existait pas (gitea-issues est dans ia_dev). Le script utilise maintenant le répertoire contenant gitea-issues (ia_dev) comme racine et appelle les sous-scripts via `GITEA_ISSUES_DIR`.
Script : `gitea-issues/agent-loop-chat-iterations.sh [N] [--repeat]`. Log : `projects/<id>/logs/gitea-issues/agent-loop-chat-iterations.log`. Boucle illimitée sans relance : `cd <racine_projet> && while true; do ./ia_dev/gitea-issues/mail-list-unread.sh; sleep 60; done`.
Script : `gitea-issues/agent-loop-chat-iterations.sh [N]`. Boucle illimitée en terminal : `cd <racine_projet> && while true; do ./ia_dev/gitea-issues/mail-list-unread.sh; sleep 60; done`.
## Référence : hooks Cursor
Les **hooks Cursor** permettent d'observer, contrôler ou modifier le cycle de l'agent (et de Tab) via des scripts. Ils ne permettent **pas** de lancer un agent depuis un script externe.
**Documentation officielle** : [cursor.com/docs/agent/hooks](https://cursor.com/docs/agent/hooks).
**Configuration** : fichier `hooks.json` à l'un des emplacements suivants (tous les hooks correspondants sont exécutés) :
- Projet : `/.cursor/hooks.json` (scripts relatifs à la racine du projet, ex. `.cursor/hooks/format.sh`)
- Utilisateur : `~/.cursor/hooks.json` (scripts relatifs à `~/.cursor/`, ex. `./hooks/format.sh`)
**Événements disponibles (Agent / Cmd+K)** : `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `beforeReadFile`, `afterFileEdit`, `beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `subagentStart`, `subagentStop`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `preCompact`, `afterAgentResponse`, `afterAgentThought`, `stop`.
**Fonctionnement** : chaque hook est un processus lancé par Cursor ; il reçoit du JSON sur l'entrée standard et peut renvoyer du JSON (ex. `continue`, `permission`: allow/deny/ask). Les hooks sont **déclenchés par** une action (ouverture de session, envoi de prompt, exécution shell, etc.) ; ils ne **déclenchent pas** une session ou un envoi de prompt. Il n'existe pas de hook du type « quand le fichier X change, lancer l'agent ».
**Hook « remonter les mails reçus »** : le projet peut définir un hook `sessionStart` ou `beforeSubmitPrompt` qui exécute un script (ex. `.cursor/hooks/remonter-mails.sh`) lisant les mails en attente depuis `projects/<id>/data/issues/*.pending` (spooler) ou `projects/<id>/logs/gitea-issues/agent-loop.pending` (legacy) et renvoyant le contenu pour injection dans le contexte ou le prompt.
**Pour lancer un agent depuis un script** : la seule option côté Cursor est d'appeler la **Cursor Agent CLI** (`agent -p "..." -f`) depuis le script (voir `AGENT_LOOP_RUN_AGENT=1` dans ce document). Les hooks ne remplacent pas cet appel.

View File

@ -0,0 +1,128 @@
# Format JSON du spooler tickets (projects/<id>/data/issues/)
Les mails et réponses sont stockés dans **projects/<id>/data/issues/** (sous ia_dev, par projet). Filtrage par **expéditeurs autorisés** (conf.json → `tickets.authorized_emails`), sans sappuyer sur le statut « non lu ». Le traitement se base uniquement sur le **statut des issues** ; aucun enregistrement nest supprimé.
## Emplacement et nommage des fichiers
- **Racine** : `ia_dev/projects/<id>/data/issues/` (id = slug projet, ex. contenu de `ai_project_id` ou `.ia_project` à la racine du dépôt)
- **Messages entrants** : `<date>_<from_sanitized>_<uid>.<status>` (le `_<uid>` assure lunicité par message IMAP)
- `date` : `YYYY-MM-DDTHHmmss` (ex. `2026-03-14T094530`)
- `from_sanitized` : adresse expéditeur rendue sûre pour le système de fichiers (ex. `user_example.com` pour `user@example.com` : `@``_`, caractères interdits remplacés ou supprimés)
- `uid` : UID IMAP (évite les doublons pour un même expéditeur à la même seconde)
- `status` : `pending` | `inprogress` | `done`
- **Réponses envoyées** : `<date>_<from_sanitized>.response` (même base de nom que le message entrant associé, extension `.response`)
- **Pièces jointes (pj)** : pour un message entrant de base `<base>` (ex. `2026-03-14T094530_laurence_lecoffre.io_42`), les pièces jointes sont dans le répertoire **`<base>.d/`** (ex. `2026-03-14T094530_laurence_lecoffre.io_42.d/`). Nommage des fichiers : `<index>_<nom_fichier_sanitifé>` (ex. `0_document.pdf`, `1_capture.png`). Le JSON du message contient le tableau `attachments` avec pour chaque entrée le chemin relatif à `data/issues/`, le type MIME et la taille pour que lagent qui traite les tickets puisse les utiliser.
Exemples :
- `2026-03-14T094530_laurence_lecoffre.io.pending` — message entrant en attente
- `2026-03-14T094530_laurence_lecoffre.io.inprogress` — en cours de traitement
- `2026-03-14T094530_laurence_lecoffre.io.done` — traité
- `2026-03-14T101200_laurence_lecoffre.io.response` — réponse envoyée
## Schéma JSON — message entrant (incoming)
Fichiers `.pending`, `.inprogress`, `.done`.
```json
{
"version": 1,
"type": "incoming",
"message_id": "<original.Message-ID@host>",
"from": "laurence@lecoffre.io",
"to": ["ai.support.lecoffreio@4nkweb.com"],
"subject": "Demande d'information",
"date": "Fri, 14 Mar 2026 09:45:30 +0100",
"body": "Texte brut du corps du mail.",
"references": ["<id1@host>", "<id2@host>"],
"in_reply_to": "<id2@host>",
"uid": "42",
"created_at": "2026-03-14T09:45:35Z",
"issue_number": null,
"status": "pending",
"attachments": [
{
"filename": "document.pdf",
"path": "2026-03-14T094530_laurence_lecoffre.io_42.d/0_document.pdf",
"content_type": "application/pdf",
"size": 12345
},
{
"filename": "capture.png",
"path": "2026-03-14T094530_laurence_lecoffre.io_42.d/1_capture.png",
"content_type": "image/png",
"size": 6789
}
]
}
```
| Champ | Type | Description |
|-------|------|-------------|
| `version` | number | Version du schéma (1) |
| `type` | string | `"incoming"` |
| `message_id` | string | En-tête Message-ID du mail |
| `from` | string | Adresse de lexpéditeur |
| `to` | string[] | Adresses destinataires |
| `subject` | string | Sujet |
| `date` | string | Date denvoi (RFC2822 ou ISO8601) |
| `body` | string | Corps du message (texte brut) |
| `references` | string[] | En-tête References (liste de Message-IDs) |
| `in_reply_to` | string \| null | En-tête In-Reply-To |
| `uid` | string | UID IMAP (pour référence, pas pour marquer lu) |
| `created_at` | string | Date décriture dans le spool (ISO8601) |
| `issue_number` | number \| null | Numéro dissue Gitea si une issue a été créée |
| `status` | string | `pending` \| `inprogress` \| `done` |
| `attachments` | array | Pièces jointes : tableau dobjets (voir ci-dessous). `path` est relatif à `data/issues/` ; les fichiers sont dans `<base>.d/`. Lagent qui traite les tickets peut les lire depuis `projects/<id>/data/issues/<path>`. |
**Objet pièce jointe** : `filename` (nom dorigine), `path` (chemin relatif sous `projects/<id>/data/issues/`), `content_type` (MIME), `size` (octets).
## Schéma JSON — réponse envoyée (response)
Fichiers `.response`.
```json
{
"version": 1,
"type": "response",
"in_reply_to_message_id": "<original.Message-ID@host>",
"to": "laurence@lecoffre.io",
"subject": "Re: Demande d'information",
"body": "Texte de la réponse envoyée par l'agent.",
"sent_at": "2026-03-14T10:12:00Z",
"created_at": "2026-03-14T10:12:00Z"
}
```
| Champ | Type | Description |
|-------|------|-------------|
| `version` | number | Version du schéma (1) |
| `type` | string | `"response"` |
| `in_reply_to_message_id` | string | Message-ID du mail auquel on répond |
| `to` | string | Destinataire de la réponse |
| `subject` | string | Sujet de la réponse (ex. Re: …) |
| `body` | string | Corps de la réponse envoyée |
| `sent_at` | string | Date denvoi (ISO8601) |
| `created_at` | string | Date de création du fichier (ISO8601) |
## Configuration projet (conf.json)
- **ticketing_url** et la config ticketing sont sous la clé **`tickets`** (et non plus sous `git`).
- **`tickets`** contient :
- `ticketing_url` : URL des issues Gitea
- `authorized_emails` :
- **`to`** : récupérer **uniquement** les mails **envoyés à** cette adresse (destinataire).
- **`from`** : récupérer **uniquement** les mails **envoyés par** ces adresses (expéditeurs autorisés).
Exemple :
```json
"tickets": {
"ticketing_url": "https://git.4nkweb.com/4nk/lecoffre_ng/issues",
"authorized_emails": {
"to": "ai.support.lecoffreio@4nkweb.com",
"from": ["ai.support.lecoffreio@4nkweb.com", "laurence@lecoffre.io"]
}
}
```
La récupération ne prend que les messages **envoyés à** `authorized_emails.to` **et** **expédités par** une des adresses de `authorized_emails.from`. Aucun marquage « lu / non lu » ; aucun enregistrement du spool nest supprimé.

View File

@ -4,10 +4,10 @@
# Runs in foreground (no background); chat can run it for a few iterations to avoid timeout.
#
# Usage:
# ./gitea-issues/agent-loop-chat-iterations.sh [N]
# ./gitea-issues/agent-loop-chat-iterations.sh [N] [--repeat]
# N = number of iterations (default 3). Each iteration: mail-list-unread.sh then sleep 60.
#
# For unbounded loop, run in terminal: while true; do ./gitea-issues/mail-list-unread.sh; sleep 60; done
# --repeat = after N iterations, relaunch (infinite loop of N-by-N runs).
# Output and mail list (expéditeur, sujet) are appended to projects/<id>/logs/gitea-issues/agent-loop-chat-iterations.log.
#
set -euo pipefail
if [ -n "${HOME:-}" ] && [ -r "$HOME/.bashrc" ]; then
@ -18,35 +18,61 @@ if [ -n "${HOME:-}" ] && [ -r "$HOME/.bashrc" ]; then
fi
[ -n "${HOME:-}" ] && [ -d "$HOME/.local/bin" ] && export PATH="$HOME/.local/bin:$PATH"
# Root = directory containing gitea-issues (ia_dev). Ensures scripts and .secrets are found
# whether this script is run from ia_dev or from project root (lecoffre_ng_test).
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
ROOT="$(cd "${GITEA_ISSUES_DIR}/.." && pwd)"
ROOT="$(cd "${GITEA_ISSUES_DIR}/../.." && pwd)"
export GITEA_ISSUES_DIR
export REPO_ROOT="${ROOT}"
cd "$ROOT"
# shellcheck source=lib.sh
source "${GITEA_ISSUES_DIR}/lib.sh" 2>/dev/null || true
LOGS_GITEA="${PROJECT_LOGS_DIR:-$ROOT/logs}/gitea-issues"
N="${1:-3}"
REPEAT=""
if [ "${1:-}" = "--repeat" ]; then
REPEAT=1
N="${2:-3}"
else
N="${1:-3}"
fi
if ! [[ "$N" =~ ^[0-9]+$ ]] || [ "$N" -lt 1 ]; then
echo "Usage: $0 [N] — N positive integer (default 3)." >&2
echo "Usage: $0 [N] [--repeat] — N positive integer (default 3). --repeat = relancer à la fin." >&2
exit 1
fi
LOG_DIR="${LOGS_GITEA}"
LOG_FILE="${LOG_DIR}/agent-loop-chat-iterations.log"
mkdir -p "$LOG_DIR"
log_and_echo() {
echo "$1" | tee -a "$LOG_FILE"
}
# Test send at launch: one test email to nicolas.cantu@pm.me
echo "[agent-loop-chat] $(date -Iseconds) — test d'envoi vers nicolas.cantu@pm.me"
if "${GITEA_ISSUES_DIR}/mail-send-reply.sh" --to "nicolas.cantu@pm.me" --subject "Test envoi - agent-loop-chat $(date +%Y-%m-%dT%H:%M:%S)" --body "Mail de test envoyé au lancement de agent-loop-chat-iterations.sh." 2>&1; then
echo "[agent-loop-chat] $(date -Iseconds) — test d'envoi OK"
log_and_echo "[agent-loop-chat] $(date -Iseconds) — test d'envoi vers nicolas.cantu@pm.me"
"${GITEA_ISSUES_DIR}/mail-send-reply.sh" --to "nicolas.cantu@pm.me" --subject "Test envoi - agent-loop-chat $(date +%Y-%m-%dT%H:%M:%S)" --body "Mail de test envoyé au lancement de agent-loop-chat-iterations.sh." 2>&1 | tee -a "$LOG_FILE"
if [ "${PIPESTATUS[0]:-0}" -eq 0 ]; then
log_and_echo "[agent-loop-chat] $(date -Iseconds) — test d'envoi OK"
else
echo "[agent-loop-chat] $(date -Iseconds) — test d'envoi échoué" >&2
log_and_echo "[agent-loop-chat] $(date -Iseconds) — test d'envoi échoué"
exit 1
fi
for i in $(seq 1 "$N"); do
echo "[agent-loop-chat] $(date -Iseconds) — iteration $i/$N"
"${GITEA_ISSUES_DIR}/mail-list-unread.sh" 2>&1 || true
run_iterations() {
for i in $(seq 1 "$N"); do
log_and_echo "[agent-loop-chat] $(date -Iseconds) — iteration $i/$N"
"${GITEA_ISSUES_DIR}/mail-list-unread.sh" 2>&1 | tee -a "$LOG_FILE" || true
if [ "$i" -lt "$N" ]; then
echo "[agent-loop-chat] $(date -Iseconds) — attente 60 s avant prochaine itération"
log_and_echo "[agent-loop-chat] $(date -Iseconds) — attente 60 s avant prochaine itération"
sleep 60
fi
done
log_and_echo "[agent-loop-chat] $(date -Iseconds)$N itérations terminées"
}
while true; do
run_iterations
if [ -z "${REPEAT:-}" ]; then
break
fi
log_and_echo "[agent-loop-chat] $(date -Iseconds) — relance"
done
echo "[agent-loop-chat] $(date -Iseconds)$N itérations terminées"

View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# One-shot retrieval: run mail-list-unread once and write output to agent-loop.pending (and status file).
# Run from repo root. Used by agent-loop agent for "x times" cycles.
#
# Usage:
# cd <racine_projet> && ./ia_dev/gitea-issues/agent-loop-retrieval-once.sh
#
set -euo pipefail
if [ -n "${HOME:-}" ] && [ -r "$HOME/.bashrc" ]; then
set +u
# shellcheck source=/dev/null
source "$HOME/.bashrc" 2>/dev/null || true
set -u
fi
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
ROOT="$(cd "${GITEA_ISSUES_DIR}/../.." && pwd)"
export GITEA_ISSUES_DIR
export REPO_ROOT="${ROOT}"
cd "$ROOT"
# shellcheck source=lib.sh
source "${GITEA_ISSUES_DIR}/lib.sh" 2>/dev/null || true
LOGS_GITEA="${PROJECT_LOGS_DIR:-$ROOT/logs}/gitea-issues"
STATUS_FILE="${AGENT_LOOP_STATUS_FILE:-$LOGS_GITEA/agent-loop.status}"
PENDING_FILE="${AGENT_LOOP_PENDING_FILE:-$LOGS_GITEA/agent-loop.pending}"
mkdir -p "$(dirname "$STATUS_FILE")"
write_status() {
printf "%s\n%s\n%s\n" "$(date -Iseconds)" "$1" "${2:-}" > "$STATUS_FILE"
}
out=""
if out=$("${GITEA_ISSUES_DIR}/mail-list-unread.sh" 2>&1); then
if echo "$out" | grep -q "UID="; then
write_status "mails_pending" "One-shot: mails non lus détectés."
printf "%s\n%s\n%s\n%s\n" "$(date -Iseconds)" "mails_pending" "---" "$out" > "$PENDING_FILE"
echo "[agent-loop-retrieval-once] $(date -Iseconds) — Mails non lus écrits dans agent-loop.pending"
else
write_status "idle" "One-shot: aucun mail non lu."
[ -f "$PENDING_FILE" ] && : > "$PENDING_FILE"
echo "[agent-loop-retrieval-once] $(date -Iseconds) — Aucun mail non lu"
fi
else
write_status "error" "mail-list-unread a échoué"
echo "[agent-loop-retrieval-once] $(date -Iseconds) — Erreur mail-list-unread" >&2
exit 1
fi

View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Treatment loop: periodically check agent-loop.pending and run Cursor Agent CLI (gitea-issues-process workflow) when non-empty.
# Run from repo root. No timeout; runs forever. Use with nohup for background.
#
# Usage:
# cd <racine_projet> && ./ia_dev/gitea-issues/agent-loop-treatment.sh
# nohup ./ia_dev/gitea-issues/agent-loop-treatment.sh >> ia_dev/projects/<id>/logs/gitea-issues/agent-loop-treatment.log 2>&1 &
#
set -euo pipefail
if [ -n "${HOME:-}" ] && [ -r "$HOME/.bashrc" ]; then
set +u
# shellcheck source=/dev/null
source "$HOME/.bashrc" 2>/dev/null || true
set -u
fi
[ -n "${HOME:-}" ] && [ -d "$HOME/.local/bin" ] && export PATH="$HOME/.local/bin:$PATH"
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(cd "${GITEA_ISSUES_DIR}/../.." && pwd)"
export GITEA_ISSUES_DIR
export REPO_ROOT="${ROOT}"
cd "$ROOT"
# shellcheck source=lib.sh
source "${GITEA_ISSUES_DIR}/lib.sh" 2>/dev/null || true
LOGS_GITEA="${PROJECT_LOGS_DIR:-$ROOT/logs}/gitea-issues"
PENDING_FILE="${AGENT_LOOP_PENDING_FILE:-$LOGS_GITEA/agent-loop.pending}"
LOG_DIR="$(dirname "$PENDING_FILE")"
mkdir -p "$LOG_DIR"
AGENT_LOOP_ENV="${GITEA_ISSUES_DIR}/../.secrets/gitea-issues/agent-loop.env"
if [ -r "$AGENT_LOOP_ENV" ]; then
set +u
# shellcheck source=/dev/null
source "$AGENT_LOOP_ENV"
set -u
fi
INTERVAL="${AGENT_LOOP_TREATMENT_INTERVAL_SEC:-60}"
AGENT_MODEL="${AGENT_LOOP_MODEL:-sonnet-4.6}"
PROMPT="Exécute le workflow mails entrants de l'agent gitea-issues-process : les mails non lus sont listés dans projects/<id>/logs/gitea-issues/agent-loop.pending. 1) Pour chaque mail listé (voir contenu dans ce fichier) : exécuter ./ia_dev/gitea-issues/mail-get-thread.sh <uid>, puis ./ia_dev/gitea-issues/mail-thread-log.sh init --uid <uid>, conserver THREAD_ID. 2) Pour chaque mail : rédiger une réponse NOUVELLE (non technique, didactique, contexte LeCoffre.io). IMPORTANT : le --body de mail-send-reply.sh doit être du texte COMPOSÉ PAR TOI, jamais une copie ou citation d'un message du fil. Envoyer avec ./ia_dev/gitea-issues/mail-send-reply.sh --to <adresse_From> --subject \"Re: ...\" --body \"<ta_réponse>\", puis mail-thread-log.sh append-sent, puis ./ia_dev/gitea-issues/mail-mark-read.sh <uid>. Répondre à tous les mails avant de marquer comme lu."
while true; do
if [ -s "$PENDING_FILE" ] && command -v agent >/dev/null 2>&1; then
echo "[agent-loop-treatment] $(date -Iseconds) — Pending non vide, lancement de l'agent Cursor."
if agent -p "$PROMPT" -f --model "$AGENT_MODEL" 2>&1; then
echo "[agent-loop-treatment] $(date -Iseconds) — Agent terminé."
else
echo "[agent-loop-treatment] $(date -Iseconds) — Agent terminé avec erreur."
fi
fi
sleep "$INTERVAL"
done

View File

@ -13,5 +13,5 @@
# AGENT_LOOP_INTERVAL_SEC=60
#
# Optional: custom paths for status and pending files
# AGENT_LOOP_STATUS_FILE=logs/gitea-issues/agent-loop.status
# AGENT_LOOP_PENDING_FILE=logs/gitea-issues/agent-loop.pending
# AGENT_LOOP_STATUS_FILE=ia_dev/projects/<id>/logs/gitea-issues/agent-loop.status
# AGENT_LOOP_PENDING_FILE=ia_dev/projects/<id>/logs/gitea-issues/agent-loop.pending

View File

@ -6,9 +6,9 @@
# ./gitea-issues/agent-loop.sh [interval_seconds]
# AGENT_LOOP_INTERVAL_SEC=120 ./gitea-issues/agent-loop.sh
#
# Witness file: logs/gitea-issues/agent-loop.status
# Witness file: projects/<id>/logs/gitea-issues/agent-loop.status (or AGENT_LOOP_STATUS_FILE)
# Updated every iteration. If mtime is older than 2*interval, consider the loop stopped.
# Pending file: logs/gitea-issues/agent-loop.pending
# Pending file: projects/<id>/logs/gitea-issues/agent-loop.pending (or AGENT_LOOP_PENDING_FILE)
# Written when unread mails exist; contains timestamp and mail list. Clear after agent run.
#
# Optional: set AGENT_LOOP_RUN_AGENT=1 to run the Cursor Agent CLI when mails are detected.
@ -27,13 +27,17 @@ fi
[ -n "${HOME:-}" ] && [ -d "$HOME/.local/bin" ] && export PATH="$HOME/.local/bin:$PATH"
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(cd "${GITEA_ISSUES_DIR}/.." && pwd)"
ROOT="$(cd "${GITEA_ISSUES_DIR}/../.." && pwd)"
export GITEA_ISSUES_DIR
export REPO_ROOT="${ROOT}"
cd "$ROOT"
# Per-project logs under projects/<id>/logs (lib.sh sets PROJECT_LOGS_DIR)
# shellcheck source=lib.sh
source "${GITEA_ISSUES_DIR}/lib.sh" 2>/dev/null || true
LOGS_GITEA="${PROJECT_LOGS_DIR:-$ROOT/logs}/gitea-issues"
# Load agent-loop parameters from .secrets (optional)
AGENT_LOOP_ENV="$ROOT/.secrets/gitea-issues/agent-loop.env"
# Load agent-loop parameters from .secrets (optional; .secrets under ia_dev)
AGENT_LOOP_ENV="${GITEA_ISSUES_DIR}/../.secrets/gitea-issues/agent-loop.env"
if [ -r "$AGENT_LOOP_ENV" ]; then
set +u
# shellcheck source=/dev/null
@ -42,8 +46,8 @@ if [ -r "$AGENT_LOOP_ENV" ]; then
fi
INTERVAL="${1:-${AGENT_LOOP_INTERVAL_SEC:-60}}"
STATUS_FILE="${AGENT_LOOP_STATUS_FILE:-$ROOT/logs/gitea-issues/agent-loop.status}"
PENDING_FILE="${AGENT_LOOP_PENDING_FILE:-$ROOT/logs/gitea-issues/agent-loop.pending}"
STATUS_FILE="${AGENT_LOOP_STATUS_FILE:-$LOGS_GITEA/agent-loop.status}"
PENDING_FILE="${AGENT_LOOP_PENDING_FILE:-$LOGS_GITEA/agent-loop.pending}"
mkdir -p "$(dirname "$STATUS_FILE")"
write_status() {
@ -55,7 +59,7 @@ write_status() {
while true; do
write_status "running" "interval=${INTERVAL}s"
out=""
if out=$(./gitea-issues/mail-list-unread.sh 2>&1); then
if out=$("${GITEA_ISSUES_DIR}/mail-list-unread.sh" 2>&1); then
if echo "$out" | grep -q "UID="; then
write_status "mails_pending" "Des mails non lus. Lancer l'agent gitea-issues-process dans Cursor."
printf "%s\n%s\n%s\n%s\n" "$(date -Iseconds)" "mails_pending" "---" "$out" > "$PENDING_FILE"
@ -65,7 +69,7 @@ while true; do
echo "[agent-loop] $(date -Iseconds) — Lancement de l'agent Cursor (workflow gitea-issues-process mails)."
AGENT_MODEL="${AGENT_LOOP_MODEL:-sonnet-4.6}"
echo "[agent-loop] $(date -Iseconds) — Modèle: $AGENT_MODEL"
AGENT_OPTS=(-p "Exécute le workflow mails entrants de l'agent gitea-issues-process : les mails non lus viennent d'être détectés. 1) Pour chaque mail listé (voir contenu dans logs/gitea-issues/agent-loop.pending) : exécuter ./gitea-issues/mail-get-thread.sh <uid>, puis ./gitea-issues/mail-thread-log.sh init --uid <uid>, conserver THREAD_ID. 2) Pour chaque mail : rédiger une réponse (non technique, didactique, contexte LeCoffre.io ; pour bug demander l'environnement, pour évolution considérer test), envoyer avec ./gitea-issues/mail-send-reply.sh, puis ./gitea-issues/mail-thread-log.sh append-sent, puis ./gitea-issues/mail-mark-read.sh <uid>. 3) Uniquement branche test, ne pas modifier les agents ni scripts d'agents. Répondre à tous les mails avant de marquer comme lu." -f --model "$AGENT_MODEL")
AGENT_OPTS=(-p "Exécute le workflow mails entrants de l'agent gitea-issues-process : les mails non lus viennent d'être détectés. 1) Pour chaque mail listé (voir contenu dans logs/gitea-issues/agent-loop.pending) : exécuter ./gitea-issues/mail-get-thread.sh <uid>, puis ./gitea-issues/mail-thread-log.sh init --uid <uid>, conserver THREAD_ID. 2) Pour chaque mail : rédiger une réponse NOUVELLE (non technique, didactique, contexte LeCoffre.io ; pour bug demander l'environnement, pour évolution considérer test). IMPORTANT : le --body de mail-send-reply.sh doit être du texte COMPOSÉ PAR TOI, jamais une copie ou citation d'un message du fil. Ne jamais renvoyer le contenu d'un message précédent (ex. réponse antérieure de ai.support). Envoyer avec ./gitea-issues/mail-send-reply.sh --to <adresse_From_du_mail_utilisateur> --subject \"Re: ...\" --body \"<ta_réponse>\", puis ./gitea-issues/mail-thread-log.sh append-sent, puis ./gitea-issues/mail-mark-read.sh <uid>. 3) Uniquement branche test, ne pas modifier les agents ni scripts d'agents. Répondre à tous les mails avant de marquer comme lu." -f --model "$AGENT_MODEL")
if agent "${AGENT_OPTS[@]}" 2>&1; then
write_status "agent_done" "Agent terminé."
else

View File

@ -10,16 +10,27 @@ GITEA_API_URL="${GITEA_API_URL:-https://git.4nkweb.com/api/v1}"
GITEA_REPO_OWNER="${GITEA_REPO_OWNER:-4nk}"
GITEA_REPO_NAME="${GITEA_REPO_NAME:-lecoffre_ng}"
# Optional: load project config from ia_dev (projects/<id>/conf.json) when gitea-issues is inside ia_dev
# Optional: load project config from ia_dev (projects/<id>/conf.json); logs and data per project under projects/<id>/
PROJECT_CONFIG_PATH=""
PROJECT_LOGS_DIR=""
DATA_ISSUES_DIR=""
if [[ -f "${GITEA_ISSUES_DIR}/../lib/project_config.sh" ]]; then
PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || true
if [[ -z "${PROJECT_ROOT:-}" || "$(basename "$PROJECT_ROOT")" = "ia_dev" ]]; then
PROJECT_ROOT="$(cd "${GITEA_ISSUES_DIR}/../.." && pwd)"
fi
IA_DEV_ROOT="$(cd "$GITEA_ISSUES_DIR/.." && pwd)"
if [[ -n "${PROJECT_ROOT:-}" ]]; then
# shellcheck source=../lib/project_config.sh
source "${GITEA_ISSUES_DIR}/../lib/project_config.sh"
if [[ -n "${PROJECT_SLUG:-}" && -n "${IA_DEV_ROOT:-}" ]]; then
PROJECT_LOGS_DIR="${IA_DEV_ROOT}/projects/${PROJECT_SLUG}/logs"
DATA_ISSUES_DIR="${IA_DEV_ROOT}/projects/${PROJECT_SLUG}/data/issues"
fi
fi
fi
export PROJECT_LOGS_DIR
export DATA_ISSUES_DIR
# Load token: GITEA_TOKEN env, then project config git.token_file, then default .secrets path
load_gitea_token() {

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Thread log: one file per email thread under logs/gitea-issues/threads/.
Thread log: one file per email thread under projects/<id>/logs/gitea-issues/threads/.
Content: exchanges (received + sent), tickets (issues), commits.
Usage:
mail-thread-log.py get-id --uid <uid> # print THREAD_ID=...
@ -21,11 +21,12 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mail_common import load_gitea_config, load_imap_config, repo_root
from project_config import project_logs_dir
def threads_dir() -> Path:
root = repo_root()
d = root / "logs" / "gitea-issues" / "threads"
"""Thread log directory: projects/<id>/logs/gitea-issues/threads/ or repo logs fallback."""
d = project_logs_dir() / "gitea-issues" / "threads"
d.mkdir(parents=True, exist_ok=True)
return d

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Thread log: one file per thread under logs/gitea-issues/threads/. Run from repo root.
# Thread log: one file per thread under projects/<id>/logs/gitea-issues/threads/. Run from repo root.
# Usage:
# ./gitea-issues/mail-thread-log.sh get-id --uid <uid>
# ./gitea-issues/mail-thread-log.sh init --uid <uid>
@ -8,7 +8,7 @@
# ./gitea-issues/mail-thread-log.sh append-commit --thread-id <id> --hash <hash> --message "..." [--branch "..."]
set -euo pipefail
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(cd "${GITEA_ISSUES_DIR}/.." && pwd)"
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(cd "${GITEA_ISSUES_DIR}/../.." && pwd)"
export GITEA_ISSUES_DIR
export REPO_ROOT="${ROOT}"
cd "$ROOT"

View File

@ -0,0 +1,92 @@
# Load project config (projects/<id>/conf.json) for tickets spooler and authorized_emails.
# Requires PROJECT_ROOT (repo root with ai_project_id) and GITEA_ISSUES_DIR (ia_dev/gitea-issues).
from __future__ import annotations
import json
import os
from pathlib import Path
def project_root() -> Path:
"""Project repo root (parent of ia_dev). Where data/issues/ and ai_project_id live."""
env_root = os.environ.get("PROJECT_ROOT")
if env_root:
return Path(env_root).resolve()
env_repo = os.environ.get("REPO_ROOT")
if env_repo:
root = Path(env_repo).resolve()
# If REPO_ROOT is ia_dev, project root is parent
if (root / "gitea-issues").is_dir():
return root.parent
return root
issues_dir = os.environ.get("GITEA_ISSUES_DIR")
if issues_dir:
return Path(issues_dir).resolve().parent.parent
return Path(__file__).resolve().parent.parent.parent
def ia_dev_root() -> Path:
"""Directory containing gitea-issues (ia_dev)."""
issues_dir = os.environ.get("GITEA_ISSUES_DIR")
if issues_dir:
return Path(issues_dir).resolve().parent
return Path(__file__).resolve().parent.parent
def load_project_config() -> dict | None:
"""Load projects/<slug>/conf.json. Returns None if not found or slug missing."""
root = project_root()
ia_dev = ia_dev_root()
slug_path = root / "ai_project_id"
if not slug_path.is_file():
slug_path = root / ".ia_project"
slug = os.environ.get("IA_PROJECT", "").strip() if os.environ.get("IA_PROJECT") else None
if not slug and slug_path.is_file():
slug = slug_path.read_text(encoding="utf-8").strip()
if not slug:
return None
conf_path = ia_dev / "projects" / slug / "conf.json"
if not conf_path.is_file():
return None
with open(conf_path, encoding="utf-8") as f:
return json.load(f)
def project_dir() -> Path | None:
"""Path to projects/<id>/ (under ia_dev). None if project config not found."""
root = project_root()
ia_dev = ia_dev_root()
slug = os.environ.get("IA_PROJECT", "").strip()
if not slug and (root / "ai_project_id").is_file():
slug = (root / "ai_project_id").read_text(encoding="utf-8").strip()
if not slug and (root / ".ia_project").is_file():
slug = (root / ".ia_project").read_text(encoding="utf-8").strip()
if not slug:
return None
return ia_dev / "projects" / slug
def data_issues_dir() -> Path:
"""Path to data/issues/ spooler under projects/<id>/ (ia_dev/projects/<id>/data/issues)."""
pd = project_dir()
if pd is not None:
return pd / "data" / "issues"
return project_root() / "data" / "issues"
def project_logs_dir() -> Path:
"""Path to logs/ under projects/<id>/ (ia_dev/projects/<id>/logs)."""
pd = project_dir()
if pd is not None:
return pd / "logs"
return project_root() / "logs"
def authorized_emails() -> dict[str, str | list[str]]:
"""Return tickets.authorized_emails (to, from list). Empty dict if missing."""
conf = load_project_config()
if not conf:
return {}
tickets = conf.get("tickets") or {}
return tickets.get("authorized_emails") or {}

View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Fetch inbox emails filtered by authorized senders (conf.json tickets.authorized_emails).
Does not use UNSEEN; does not mark as read. Writes each matching message to projects/<id>/data/issues/
as JSON (<date>_<from_sanitized>_<uid>.pending). One file per message.
Usage: run from project root with GITEA_ISSUES_DIR and PROJECT_ROOT set (e.g. via tickets-fetch-inbox.sh).
"""
from __future__ import annotations
import email
import imaplib
import json
import re
import sys
from datetime import datetime, timezone
from email.header import decode_header
from email.utils import parsedate_to_datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mail_common import load_imap_config, imap_ssl_context
from project_config import authorized_emails, data_issues_dir, load_project_config
def decode_header_value(header: str | None) -> str:
if not header:
return ""
parts = decode_header(header)
result = []
for part, charset in parts:
if isinstance(part, bytes):
result.append(part.decode(charset or "utf-8", errors="replace"))
else:
result.append(part)
return "".join(result)
def parse_from_address(from_header: str) -> str:
"""Extract email address from From header (e.g. 'Name <user@host>' -> user@host)."""
if not from_header:
return ""
match = re.search(r"<([^>]+)>", from_header)
if match:
return match.group(1).strip().lower()
return from_header.strip().lower()
def get_text_body(msg: email.message.Message) -> str:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload:
return payload.decode(
part.get_content_charset() or "utf-8", errors="replace"
)
return ""
payload = msg.get_payload(decode=True)
if not payload:
return ""
return payload.decode(
msg.get_content_charset() or "utf-8", errors="replace"
)
def is_sent_to_alias(msg: email.message.Message, filter_to: str) -> bool:
if not filter_to:
return True
headers_to_check = ("To", "Delivered-To", "X-Original-To", "Cc", "Envelope-To")
for name in headers_to_check:
value = msg.get(name)
if value:
decoded = decode_header_value(value).lower()
if filter_to in decoded:
return True
return False
def sanitize_from_for_filename(email_addr: str) -> str:
"""Filesystem-safe string from email (e.g. user@example.com -> user_example.com)."""
return re.sub(r"[^a-zA-Z0-9._-]", "_", email_addr.replace("@", "_"))
def sanitize_attachment_filename(name: str) -> str:
"""Safe filename for attachment (no path, no dangerous chars)."""
if not name or not name.strip():
return "attachment"
base = Path(name).name
return re.sub(r"[^a-zA-Z0-9._-]", "_", base)[:200] or "attachment"
def get_attachments(msg: email.message.Message) -> list[tuple[str, bytes, str]]:
"""Return list of (filename, payload_bytes, content_type) for each attachment."""
result: list[tuple[str, bytes, str]] = []
for part in msg.walk():
content_type = (part.get_content_type() or "").lower()
if content_type.startswith("multipart/"):
continue
filename = part.get_filename()
if not filename:
# Optional: treat inline images etc. with Content-Disposition attachment
disp = part.get("Content-Disposition") or ""
if "attachment" in disp.lower():
ext = ""
if "image" in content_type:
ext = ".bin" if "octet-stream" in content_type else ".img"
filename = f"attachment{ext}"
else:
continue
filename = decode_header_value(filename).strip()
if not filename:
continue
payload = part.get_payload(decode=True)
if payload is None:
continue
result.append((filename, payload, content_type))
return result
def parse_references(refs: str | None) -> list[str]:
if not refs:
return []
return [x.strip() for x in re.split(r"\s+", refs) if x.strip()]
def main() -> int:
conf = load_project_config()
if not conf:
print("[tickets-fetch-inbox] No project config (projects/<id>/conf.json).", file=sys.stderr)
return 1
auth = authorized_emails()
filter_to = (auth.get("to") or "").strip().lower()
from_list = auth.get("from")
if isinstance(from_list, list):
allowed_from = {a.strip().lower() for a in from_list if a}
else:
allowed_from = set()
if not filter_to or not allowed_from:
print(
"[tickets-fetch-inbox] tickets.authorized_emails.to and .from required in conf.json.",
file=sys.stderr,
)
return 1
cfg = load_imap_config()
if not cfg["user"] or not cfg["password"]:
print("[tickets-fetch-inbox] IMAP_USER and IMAP_PASSWORD required.", file=sys.stderr)
return 1
spool = data_issues_dir()
spool.mkdir(parents=True, exist_ok=True)
mail = imaplib.IMAP4(cfg["host"], int(cfg["port"]))
if cfg["use_starttls"]:
mail.starttls(imap_ssl_context(cfg.get("ssl_verify", True)))
mail.login(cfg["user"], cfg["password"])
mail.select("INBOX")
# Do not use UNSEEN; fetch all (or SINCE to limit). Filter by authorized senders only.
_, nums = mail.search(None, "ALL")
ids = nums[0].split()
written = 0
for uid in ids:
uid_s = uid.decode("ascii")
_, data = mail.fetch(uid, "(RFC822)")
if not data or not data[0]:
continue
msg = email.message_from_bytes(data[0][1])
if not is_sent_to_alias(msg, filter_to):
continue
from_raw = decode_header_value(msg.get("From"))
from_addr = parse_from_address(from_raw)
if from_addr not in allowed_from:
continue
mid = (msg.get("Message-ID") or "").strip()
to_raw = decode_header_value(msg.get("To"))
to_addrs = [a.strip() for a in re.split(r"[,;]", to_raw) if a.strip()]
subj = decode_header_value(msg.get("Subject"))
date_h = decode_header_value(msg.get("Date"))
refs = parse_references(msg.get("References"))
in_reply_to = (msg.get("In-Reply-To") or "").strip() or None
body = get_text_body(msg)
try:
if date_h:
dt = parsedate_to_datetime(date_h)
date_str = dt.strftime("%Y-%m-%dT%H%M%S")
else:
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
except Exception:
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
from_safe = sanitize_from_for_filename(from_addr)
base = f"{date_str}_{from_safe}_{uid_s}"
path = spool / f"{base}.pending"
if path.exists():
continue
created_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
attachments_meta: list[dict[str, str | int]] = []
attachment_parts = get_attachments(msg)
if attachment_parts:
att_dir = spool / f"{base}.d"
att_dir.mkdir(parents=True, exist_ok=True)
for idx, (orig_name, payload_bytes, content_type) in enumerate(attachment_parts):
safe_name = sanitize_attachment_filename(orig_name)
stored_name = f"{idx}_{safe_name}"
stored_path = att_dir / stored_name
stored_path.write_bytes(payload_bytes)
rel_path = f"{base}.d/{stored_name}"
attachments_meta.append({
"filename": orig_name,
"path": rel_path,
"content_type": content_type,
"size": len(payload_bytes),
})
payload = {
"version": 1,
"type": "incoming",
"message_id": mid or "",
"from": from_addr,
"to": to_addrs,
"subject": subj,
"date": date_h or "",
"body": body or "",
"references": refs,
"in_reply_to": in_reply_to,
"uid": uid_s,
"created_at": created_at,
"issue_number": None,
"status": "pending",
"attachments": attachments_meta,
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
written += 1
print(f"[tickets-fetch-inbox] Wrote {path.name}")
mail.logout()
print(f"[tickets-fetch-inbox] Done. Wrote {written} new message(s) to {spool}.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Fetch inbox messages filtered by tickets.authorized_emails (conf.json). No UNSEEN; no mark read.
# Writes new messages to projects/<id>/data/issues/ (ia_dev) as JSON (<date>_<from>_<uid>.pending).
# Usage: cd <racine_projet> && ./ia_dev/gitea-issues/tickets-fetch-inbox.sh
set -euo pipefail
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
PROJECT_ROOT="$(cd "${GITEA_ISSUES_DIR}/../.." && pwd)"
export GITEA_ISSUES_DIR
export PROJECT_ROOT
export REPO_ROOT="${GITEA_ISSUES_DIR}/.."
cd "$PROJECT_ROOT"
exec python3 "${GITEA_ISSUES_DIR}/tickets-fetch-inbox.py" "$@"

View File

@ -25,7 +25,8 @@ One JSON file per project: `projects/<id>/conf.json` (e.g. `projects/lecoffreio/
| `version.package_json_paths` | no | List of paths (relative to repo root) to `package.json` files to update on bump |
| `version.splash_app_name` | no | App name used in splash message template |
| `mail` | no | Mail/imap bridge config |
| `git` | no | Git hosting: `wiki_url`, `ticketing_url`, `token_file` (path relative to repo root for token file) |
| `git` | no | Git hosting: `wiki_url`, `token_file` (path relative to repo root for token file). Ticketing URL is under `tickets`, not `git`. |
| `tickets` | no | Ticketing: `ticketing_url` (Gitea issues URL), `authorized_emails` (`to`: alias, `from`: list of allowed sender addresses for mail-based ticketing). See gitea-issues/TICKETS_SPOOL_FORMAT.md. |
## Example (minimal)

31
projects/enso/conf.json Normal file
View File

@ -0,0 +1,31 @@
{
"id": "enso",
"name": "enso",
"project_path": "../",
"base_path": "lecofensofre_ng_test",
"build_dirs": [],
"deploy": {
"scripts_path": "../deploy/scripts_v2",
"deploy_script_path": "../deploy/scripts_v2/deploy.sh",
"secrets_path": "../.secrets"
},
"version": {
"package_json_paths": [],
"splash_app_name": "Enso"
},
"mail": {
"email": "ai.support.enso@4nkweb.com",
"imap_bridge_env": ".secrets/gitea-issues/imap-bridge.env"
},
"git": {
"wiki_url": "https://git.4nkweb.com/nicolas.cantu/enso/wiki",
"token_file": ".secrets/gitea-issues/token"
},
"tickets": {
"ticketing_url": "https://git.4nkweb.com/nicolas.cantu/enso/issues",
"authorized_emails": {
"to": "ai.support.enso@4nkweb.com",
"from": ["ai.support.enso@4nkweb.com"]
}
}
}

View File

@ -15,7 +15,13 @@
},
"git": {
"wiki_url": "https://git.4nkweb.com/4nk/ia_dev/wiki",
"ticketing_url": "https://git.4nkweb.com/4nk/ia_dev/issues",
"token_file": ".secrets/gitea-issues/token"
},
"tickets": {
"ticketing_url": "https://git.4nkweb.com/4nk/ia_dev/issues",
"authorized_emails": {
"to": "ai.support.ia_dev@4nkweb.com",
"from": ["ai.support.ia_dev@4nkweb.com"]
}
}
}

View File

@ -26,7 +26,13 @@
},
"git": {
"wiki_url": "https://git.4nkweb.com/4nk/lecoffre_ng/wiki",
"ticketing_url": "https://git.4nkweb.com/4nk/lecoffre_ng/issues",
"token_file": ".secrets/gitea-issues/token"
},
"tickets": {
"ticketing_url": "https://git.4nkweb.com/4nk/lecoffre_ng/issues",
"authorized_emails": {
"to": "ai.support.lecoffreio@4nkweb.com",
"from": ["ai.support.lecoffreio@4nkweb.com", "laurence@lecoffre.io"]
}
}
}