# SETUP — missioflow-handoff-mcp

Spec de référence du serveur MCP de coordination cross-projet. Capture
l'état stabilisé au 2026-05-09 (pré-évolution `heartbeat` + `mf_health`)
et la règle d'évolution qui détermine quand ajouter une couche.

Lecture obligatoire avant d'étendre le serveur. Le but est d'éviter le
sur-design en codifiant **ce qui est shipped**, **ce qui est designé
mais pas encore shipped** et **ce qu'on s'interdit de coder tant qu'un
besoin réel n'est pas observé**.

---

## 1. Architecture actuelle (shipped)

### Stack

- **Runtime** : Node ≥ 22 (utilise `node:sqlite` builtin, pas
  `better-sqlite3`).
- **Persistance** : un seul fichier SQLite, mode WAL, dans
  `data/handoff.sqlite` (override via `MCP_HANDOFF_DB`).
- **Transport** : stdio JSON-RPC (MCP standard). Un process
  `server.mjs` est spawné par chaque session Claude.
- **Web UI read-only** : `web.mjs` sur `127.0.0.1:7878` (pas dans le
  scope de ce SETUP, voir README).
- **Dépendance externe** : `@modelcontextprotocol/sdk`. C'est tout.

### Schéma SQLite

```sql
CREATE TABLE messages (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  parent_id     INTEGER REFERENCES messages(id),  -- NULL = question racine
  from_session  TEXT NOT NULL,                    -- 'panel' | 'app' | …
  to_session    TEXT,                             -- NULL = broadcast
  subject       TEXT,
  body          TEXT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'open',     -- 'open' | 'answered' | 'closed'
  created_at    INTEGER NOT NULL,                 -- unix seconds
  resolved_at   INTEGER                           -- unix seconds, NULL tant qu'open
);

CREATE INDEX idx_messages_to_status   ON messages(to_session, status, created_at);
CREATE INDEX idx_messages_parent      ON messages(parent_id);
```

Une seule table. Les réponses sont des messages avec `parent_id` qui
pointe vers la question racine et `status='closed'`. La racine voit son
`status` passer à `'answered'` et son `resolved_at` être stampé. Pas de
threading récursif : une question, plusieurs réponses, point.

### Identité de session

Chaque process serveur reçoit `MCP_SESSION_ID` via env (`panel`, `app`,
…). Cette valeur :
- est stampée comme `from_session` sur tout message émis par cette
  session ;
- filtre `mf_inbox` (la session voit ce qui lui est adressé ou
  broadcast) ;
- empêche la self-reply (`mf_ask` rejette `to === SESSION`).

### Ajouter un backend / une session

Source unique de vérité : [`src/backends.mjs`](src/backends.mjs). **Ajouter un
backend = une entrée dans le tableau `BACKENDS`** (`key`, `color`, `desc`).
Tout en dérive automatiquement :

- `server.mjs` → `VALID_SESSIONS` + les `enum` des outils `mf_*` + leurs
  descriptions ;
- `db.mjs` → `VALID_PROJECTS` (file de plans) ;
- `web.mjs` → endpoint `/api/backends` ; `index.html` génère colonnes du board
  + couleurs dynamiquement (aucune édition HTML/CSS). L'ordre du tableau =
  l'ordre des colonnes projet.

Seule contrainte restante (inhérente à MCP) : les `enum` d'outils sont négociés
au handshake `initialize`. Une **session déjà ouverte doit se reconnecter**
(relancer Claude Code / reconnect MCP) pour voir le nouveau backend. Le
dashboard, lui, se met à jour au refresh navigateur (et la `web.mjs` tourne sous
systemd avec `Restart=always`, donc un `kill` suffit à recharger son code).

---

## 2. Les 6 outils — contrat exact

Chaque outil retourne du texte MCP (`{content: [{type:"text", text}]}`)
en cas de succès, ou `{isError: true}` avec un message en cas d'échec.
Les schémas sont définis dans `server.mjs` (`TOOLS` array).

### `mf_ask(to, subject, body)`

Pose une nouvelle question.

| Champ | Type | Requis | Contrainte |
|---|---|---|---|
| `to` | enum `panel \| app` | ✓ | doit être ≠ session courante |
| `subject` | string | ✓ | 1 ligne, affichée dans `mf_inbox` |
| `body` | string | ✓ | markdown supporté |

**Effet** : `INSERT INTO messages (parent_id=NULL, from=SESSION, to,
subject, body, status='open', created_at=now())`.
**Retour** : `Question créée #<id> (<from> → <to>). L'autre session la
verra à son prochain mf_inbox.`

### `mf_inbox(status?, limit?)`

Liste les questions adressées à la session courante.

| Champ | Type | Défaut |
|---|---|---|
| `status` | enum `open \| answered \| closed \| all` | `open` |
| `limit` | number | 50 |

**Filtre SQL** : `parent_id IS NULL AND status=? AND (to_session=SESSION
OR to_session IS NULL)`.
**Retour** : liste formatée `#<id> [<status>] <subject>` + preview
200 chars du body.
**Read-only** : aucun side-effect sur les données.

### `mf_answer(message_id, body)`

Répond à une question existante.

| Champ | Type | Requis |
|---|---|---|
| `message_id` | number | ✓ |
| `body` | string | ✓ |

**Pré-condition** : la question doit exister, être racine
(`parent_id IS NULL`) et ne pas être `closed`.
**Effet (transactionnel)** :
1. `INSERT` un nouveau message avec `parent_id=message_id`,
   `from=SESSION`, `status='closed'` ;
2. `UPDATE` la racine : `status='answered'`, `resolved_at=now()`.
**Retour** : `Réponse postée à #<message_id> (par <session>, message
<id>).`

### `mf_close(message_id)`

Ferme une question sans y répondre (obsolète, résolue ailleurs).

| Champ | Type | Requis |
|---|---|---|
| `message_id` | number | ✓ |

**Effet** : `UPDATE messages SET status='closed', resolved_at=COALESCE(
resolved_at, now()) WHERE id=? AND parent_id IS NULL`.
**Retour** : `Question #<id> fermée.`

### `mf_history(subject_contains?, from_session?, to_session?, status?, limit?)`

Recherche dans l'historique. Tous les filtres sont optionnels et se
combinent en AND.

| Champ | Type | Notes |
|---|---|---|
| `subject_contains` | string | `LIKE %x%` |
| `from_session` | enum `panel \| app` | |
| `to_session` | enum `panel \| app` | |
| `status` | enum `open \| answered \| closed` | |
| `limit` | number | défaut 20 |

**Retour** : liste plate (sans body complet) :
`#<id> [<status>] (<from>→<to>) <ts> — <subject>`.
**Read-only**.

### `mf_thread(message_id)`

Récupère un fil complet : la question racine + toutes les réponses dans
l'ordre chronologique.

| Champ | Type | Requis |
|---|---|---|
| `message_id` | number | ✓ |

**Retour** : bloc texte structuré (entête + corps de la racine, puis
chaque réponse séparée par `── Réponse #X de Y (ts) ──`).
**Read-only**.

---

## 3. Heartbeat implicite (designed, pas encore shipped)

### Problème à résoudre

Les sessions Claude finissent leurs tours sans signal explicite : pas
de `bye`, pas de `shutdown`. Une session qui a posté une question et
qui ferme son tour laisse un message `open` qui peut rester pendant
des heures sans qu'on sache si l'autre côté est encore là.

À court terme : pas critique (l'humain est dans la boucle).
À l'échelle 4 sessions : devient un problème de visibilité.

### Solution : `last_seen` stampé à chaque appel

Pas d'outil dédié `mf_heartbeat`. Le serveur stampe `last_seen=now()`
**à chaque `CallToolRequestSchema`**, avant le dispatch. Une session
qui ne fait que `mf_inbox` en début de tour est automatiquement
marquée vivante. Une session qui n'appelle rien pendant N minutes est
silencieuse, sans qu'elle ait à le déclarer.

### Patch de schéma

```sql
CREATE TABLE IF NOT EXISTS sessions (
  name      TEXT PRIMARY KEY,
  last_seen INTEGER NOT NULL                 -- unix seconds
);
```

### Patch côté `server.mjs`

Dans le handler de `CallToolRequestSchema`, en tout début (avant le
`switch`) :

```js
db.prepare(
  "INSERT INTO sessions (name, last_seen) VALUES (?, ?) " +
  "ON CONFLICT(name) DO UPDATE SET last_seen = excluded.last_seen"
).run(SESSION, Math.floor(Date.now() / 1000));
```

Une seule requête, idempotente, négligeable en coût. Pas de cron, pas
de job de cleanup.

### Pourquoi pas une vue `MAX(created_at) GROUP BY from_session` ?

Tentant car ça évite la nouvelle table. Mais :
- une session qui ne fait que des `mf_inbox` (read-only) n'apparaît
  jamais dans `messages.from_session` → fausse stale alors qu'elle est
  active ;
- la sémantique "dernier heartbeat" est différente de "dernier
  message émis" — les confondre revient à recommencer dans 3 mois.

Donc : table dédiée.

---

## 4. `mf_health` (designed, pas encore shipped)

### Contrat

Outil read-only qui retourne l'état de toutes les sessions vues par le
serveur, classées en 3 buckets définis **uniquement par l'âge du
heartbeat**.

| Bucket | Définition | Interprétation |
|---|---|---|
| `active` | `now - last_seen ≤ 5 min` | session a appelé un outil dans les 5 dernières minutes |
| `idle`   | `5 min < now - last_seen ≤ 60 min` | session probablement entre deux interactions humain |
| `stale`  | `now - last_seen > 60 min` ou jamais vue | session probablement fermée / crashée |

Les seuils sont fixes pour l'instant (pas de paramétrage). Ils peuvent
être réévalués empiriquement plus tard, à partir de données.

### Requêtes SQLite correspondantes

Vue de base :

```sql
SELECT
  s.name,
  s.last_seen,
  CAST(strftime('%s', 'now') AS INTEGER) - s.last_seen AS age_sec,
  CASE
    WHEN CAST(strftime('%s', 'now') AS INTEGER) - s.last_seen <= 300   THEN 'active'
    WHEN CAST(strftime('%s', 'now') AS INTEGER) - s.last_seen <= 3600  THEN 'idle'
    ELSE 'stale'
  END AS bucket
FROM sessions s
ORDER BY s.last_seen DESC;
```

Aggrégat par bucket :

```sql
SELECT bucket, COUNT(*) AS n
FROM (<requête ci-dessus>)
GROUP BY bucket;
```

Inbox suspecte (questions `open` adressées à une session `stale`) — la
question la plus utile pour l'humain :

```sql
SELECT m.id, m.from_session, m.to_session, m.subject, m.created_at
FROM messages m
LEFT JOIN sessions s ON s.name = m.to_session
WHERE m.parent_id IS NULL
  AND m.status = 'open'
  AND (s.last_seen IS NULL
       OR CAST(strftime('%s', 'now') AS INTEGER) - s.last_seen > 3600);
```

### Forme du retour proposée

```json
{
  "sessions": [
    {"name": "panel", "last_seen": 1715253012, "age_sec": 42, "bucket": "active"},
    {"name": "app",   "last_seen": 1715250000, "age_sec": 3054, "bucket": "idle"}
  ],
  "summary": {"active": 1, "idle": 1, "stale": 0},
  "open_questions_to_stale": []
}
```

Format texte rendu côté serveur, comme les autres outils.

### Ce que `mf_health` ne dit PAS

- Si une session est *bloquée en attente d'une autre* — il n'existe
  aucun état déclaré pour ça à ce stade. Une session `stale` qui a
  posé une question `open` peut être "morte" ou "elle attend qu'on lui
  réponde et l'humain n'est pas devant l'écran". On ne tranche pas.
- Aucun cycle, aucun graphe d'attente. La donnée n'est pas là.

C'est intentionnel — voir section 5.

---

## 5. Règle d'évolution (la décision la plus importante)

Le système actuel résout un problème simple — passer de la coordination
async via markdown à la coordination async via API structurée.
L'évolution de cette base ne doit se faire que sur **un signal observé
en production**, jamais sur un signal anticipé.

### Paliers et conditions de déclenchement

| Palier | État | Condition pour passer au suivant |
|---|---|---|
| **0 — actuel** | 6 outils, persistance simple, 2 sessions actives | Aucune. Rester ici tant que la liste des questions reste lisible et qu'aucune session ne se perd. |
| **1 — heartbeat + health** | + table `sessions`, + `mf_health` | Une session a oublié qu'elle attendait l'autre, ou un humain a perdu du temps à comprendre qui attend qui. **Observé, pas anticipé.** |
| **2 — multi-destinataires** | `to` accepte un array ou `*` | On a 3+ sessions actives ET une question pertinente pour 2 destinataires se pose en pratique. |
| **3 — couches additionnelles** | À discuter au cas par cas | Un nouveau cas d'usage est entré en collision avec le modèle "questions adressées" et a fait perdre du temps de manière répétée (≥ 3 incidents). |

### Ce qu'on s'interdit explicitement

Tant qu'aucun palier supérieur n'est déclenché par un cas réel :
- pas de namespace de catégorisation des messages (laisser
  `subject` faire ce travail) ;
- pas d'état déclaré par les sessions au-delà du heartbeat
  (active/idle/stale suffit) ;
- pas de détection automatique de cycles de dépendance (la donnée
  n'est pas là, et pour 2-4 sessions un humain qui regarde
  `mf_health` une fois par jour est plus efficace) ;
- pas de pub/sub, pas de broker externe, pas de Kafka-de-pauvre.

> Documenté nulle part = pas de tentation de coder.

### Comment justifier une nouvelle couche

Quand on pense qu'un palier doit être franchi, écrire dans la PR qui
introduit la couche :
1. **Le cas réel observé** (ID de la question concernée, date, durée
   du blocage).
2. **Pourquoi le palier précédent ne suffit pas** (pas
   "ça serait plus pratique").
3. **Le palier suivant qui sera ainsi débloqué** (i.e. après cette
   couche, qu'est-ce qui devient possible qui ne l'était pas).

Sans ces 3 points dans la PR, refuser le merge.

---

## 6. Convention de nommage des outils

Toute nouvelle extension respecte ces règles :

### Forme

```
mf_<verbe>[_<scope>]
```

- **Préfixe `mf_`** systématique. Les outils MCP partagent un
  namespace global ; le préfixe garantit qu'on ne collisionne pas
  avec un outil d'un autre serveur.
- **`<verbe>`** en anglais, snake_case, à l'infinitif ou impératif :
  `ask`, `answer`, `close`, `inbox`, `history`, `thread`, `health`.
- **`<scope>`** optionnel, ajouté seulement si le verbe seul est
  ambigu hors contexte. Préférer un verbe clair sans scope.

### Versioning

Pas de `_v2` dans le nom. Les évolutions se font par :
- ajout de paramètres optionnels (backward-compat) ;
- nouveau outil avec un nom différent si la sémantique change
  fondamentalement (ex: `mf_ask` reste pour binaire, futur
  `mf_broadcast` pour multi-destinataires).

### Erreurs

Messages d'erreur en **français** (UX du user du projet), structurés
sous forme `Erreur: <description courte>` ou `Champs requis: <liste>`.
Le canal de retour est le même `content[0].text` mais avec
`isError: true`.

### Idempotence

Les outils read-only (`mf_inbox`, `mf_history`, `mf_thread`,
futur `mf_health`) ne mutent jamais. Les outils mutateurs sont au
maximum idempotents (`mf_close` sur une question déjà fermée doit
être un no-op silencieux, pas une erreur — ce comportement existe
déjà via `COALESCE(resolved_at, now)`).

### Réponses

Format texte, pas JSON brut. Le client (Claude) lit du texte. Si une
structure est nécessaire, la rendre lisible par un humain qui regarde
la transcription, pas par une regex.

---

## 7. Smoke test à mettre à jour quand on ajoute une couche

`test/smoke.mjs` couvre actuellement le happy path
ask → inbox → answer → thread → close → history. À chaque palier
franchi, étendre le smoke avec :
- les nouvelles requêtes du palier ;
- au moins une vérification d'invariant (ex: `mf_health` après
  N appels doit montrer `last_seen` cohérent).

Lancer toujours via `MCP_HANDOFF_DB=/tmp/<unique>.sqlite npm run
smoke` pour ne pas polluer la DB de prod.

---

## 8. Plans & tâches — agentic OS v0.2 (PR1 shipped)

### Pourquoi cette couche

Les 6 outils `mf_*` initiaux gèrent la communication Q/R entre sessions.
La couche `plans`/`tasks` permet à un humain d'écrire une **séquence
d'étapes atomiques** que Claude consomme **une à une**, sans accès à la
liste complète. Le serveur pilote la séquence, plus le jugement de Claude.

### Schéma additif (déjà shipped)

```sql
CREATE TABLE plans (
  id, project, title, status, created_at, updated_at
);

CREATE TABLE tasks (
  id, plan_id, position,
  title, instructions,
  validation_mode, validation_rule,   -- PR1 : seul 'manual' est supporté
  status,                              -- pending|current|awaiting_human|validating|done|failed|blocked|skipped
  attempts, last_proof, last_error,
  started_at, completed_at,
  created_at, updated_at
);

-- Invariant central : au plus UNE tâche active par plan.
CREATE UNIQUE INDEX idx_tasks_one_active_per_plan
  ON tasks(plan_id)
  WHERE status IN ('current', 'awaiting_human', 'validating');
```

### Outils MCP ajoutés

| Outil | Effet |
|---|---|
| `mf_next_step()` | Retourne **une seule** tâche (la current, ou promote la pending suivante). Idempotent. |
| `mf_validate_step(task_id)` | Mode manual : transitionne `current` → `awaiting_human`. Claude doit s'arrêter. |

`mf_block_step` arrivera en PR2 avec le runner shell.

### Boucle PR1 (mode `manual`)

```
[humain écrit le plan en SQL]
  ↓
Claude → mf_next_step → "Étape #N — ..."
Claude exécute
Claude → mf_validate_step(N)
  ↓ status: current → awaiting_human
[humain regarde le board, coche "valider"]
  ↓ POST /api/tasks/N/action {action:"validate-manual"}
  ↓ status: awaiting_human → done
Claude → mf_next_step → étape suivante
```

### Recovery au boot

Toute tâche en `validating` au démarrage est marquée `failed` avec
`last_error='runner_crashed'`. Les tâches `awaiting_human` ne sont
**pas** touchées (l'attente humaine persiste à travers les reboots).

### Smoke tests

`test/plans.mjs` couvre les invariants #1, #2, #3, #6, #9. Lancer via
`MCP_HANDOFF_DB=/tmp/plans.sqlite npm run smoke:plans`.

### Convention "Claude ne lit pas le plan complet"

Garantie par construction : aucun outil MCP ne retourne plus d'une
tâche à la fois, et aucun outil ne liste les suivantes. Si on ajoute
un futur outil qui contredit cette garantie, **mentionner explicitement
la régression** dans la PR.

### PR3 shipped — édition UI riche

Création, édition, reorder et suppression de plans/tâches **directement
depuis le board**, sans SQL.

**Endpoints HTTP** (127.0.0.1 only, comme l'existant) :
- `POST   /api/plans` — `{project, title}` → crée plan `active`
- `POST   /api/plans/:id/tasks` — `{title, instructions, validation_mode, validation_rule?}` → append (position = MAX + 10)
- `PATCH  /api/tasks/:id` — édite `{title?, instructions?, validation_mode?, validation_rule?}`. **Refusé si la tâche est active** (status ∈ {`current`, `awaiting_human`, `validating`}) — évite de changer les instructions sous le nez de Claude
- `POST   /api/tasks/:id/move` — `{direction: 'up'|'down'}` → swap des `position` avec la tâche adjacente
- `DELETE /api/tasks/:id` — suppression. Idem PATCH : refusé si active

**Helpers DB exposés** : `createPlan`, `addTask`, `patchTask`,
`moveTask`, `deleteTask` (cf. `src/db.mjs`). Tous bumpent
`plans.updated_at` pour déclencher le push SSE.

**UI** (`public/index.html`) :
- Bouton **"+ Nouveau plan"** en tête de chaque colonne projet
- Bouton **"+ Ajouter une étape"** en bas de chaque plan
- Sur chaque tâche non-active : boutons **Edit** (modal), **↑** / **↓** (reorder par swap), **🗑** (delete)
- Boutons existants conservés : Valider (sur `awaiting_human`), Reset (sur `failed`/`blocked`), Skip
- Modal réutilisé pour création + édition, avec validation client/serveur
- Mode select limité à `manual` en PR3 — sera étendu quand PR2 ajoutera `shell` + `artifact`

**Reorder via swap** : le gap de 10 entre `position` ne sert qu'à
permettre l'**insertion** d'une tâche entre deux existantes. Le swap
échange simplement les `position` de deux tâches adjacentes, sans
renumérotation globale.

### PR2 (designed, pas shipped)

- Runner shell (cmd + cwd + timeout 120s) + 3 règles artifact
  (`file_exists`, `file_contains`, `http_get_status`) + outil
  `mf_block_step`. Requiert env `MCP_PROJECT_CWD` dans chaque
  `.mcp.json`. Le mode select du modal s'étendra automatiquement à
  `shell` + `artifact` une fois PR2 mergée.

### Ce qu'on s'interdit (cf §5)

- Pas de DAG cross-projet (chaque projet a sa file FIFO indépendante).
- Pas de Claude qui propose un plan (humain seul, via SQL en PR1 puis
  UI en PR3).
- Pas d'historique des tentatives (seule la dernière `last_proof` est
  gardée).
- Pas de retry auto / backoff.
- Pas d'extension à `site` ou `mobile` tant que la couche n'est pas
  stable sur `app` + `panel`.
