# Planification cross-session (`mf_plan`)

> Spec d'implémentation : permettre à **une session Claude de créer un plan
> d'étapes pour une autre session** (ou pour elle-même), avec **garde
> d'approbation humaine** au board.
>
> Statut : **proposé, pas encore shippé.** Ce document est le plan de mise en
> place. Aligné sur la « Règle d'évolution » de [SETUP.md](../SETUP.md) §5 :
> on garde l'humain dans la boucle par défaut.

---

## 1. Pourquoi / état actuel

Aujourd'hui une session ne peut que **consommer** sa file de plans
(`mf_next_step`) et **valider** une étape (`mf_validate_step`). La **création**
de plans/tâches passe uniquement par le board (`POST /api/plans`,
`POST /api/plans/:id/tasks`) → c'est l'humain qui planifie.

Le seul canal session→session existant est `mf_ask` (poser une question), qui
ne crée **pas** d'étape exécutable dans la file de l'autre.

**Objectif :** une session peut déposer un plan complet (titre + N étapes) dans
la file d'une session cible. Le plan n'est **pas exécutable tant qu'un humain
ne l'a pas approuvé** au board.

---

## 2. Décisions de design

| Question | Décision retenue | Pourquoi |
|---|---|---|
| Auto-actif ou gate humaine ? | **Gate humaine par défaut.** Plan créé via MCP = `approved_at NULL` ; invisible pour `mf_next_step` tant que non approuvé au board. | Cohérent avec le reste (mf_validate_step exige déjà une coche humaine). Évite qu'une session pilote silencieusement une autre. |
| Qui peut planifier pour qui ? | **Permissif + audité.** N'importe quelle session peut cibler n'importe quel backend (y compris elle-même). On stocke `created_by`. | La sécurité réelle, c'est la gate humaine. Une allowlist est possible plus tard (cf. §9). |
| 1 outil ou 2 ? | **1 outil atomique `mf_plan(project, title, tasks[])`.** | Un seul appel pour le LLM, création transactionnelle (plan + tâches ensemble ou rien). |
| Mode de validation des tâches | Forcé à `manual` (seul mode shippé, PR1). | `shell`/`artifact` viendront plus tard (SETUP §8). |
| Notification de la cible | Optionnel : auto-`mf_ask` à la cible (cf. §8). | Nice-to-have, pas bloquant. |

**Invariant ajouté :** `mf_next_step` ne sert **que** des tâches de plans
`approved_at IS NOT NULL`. Les plans créés au board (`created_by='human'`) sont
**auto-approuvés** (`approved_at = created_at`).

---

## 3. Schéma SQLite (migration additive)

Le projet n'a pas de migrations versionnées (`CREATE TABLE IF NOT EXISTS`).
On ajoute deux colonnes à `plans` via une **migration additive idempotente**
dans `initDb()` (pattern `PRAGMA table_info` + `ALTER TABLE ADD COLUMN`).

```sql
-- plans : nouvelles colonnes
created_by  TEXT NOT NULL DEFAULT 'human',  -- 'human' | <session> (app|mobile|coolcare|panel)
approved_at INTEGER                          -- NULL = en attente d'approbation
```

Patch dans [`src/db.mjs`](../src/db.mjs) `initDb()`, après le `CREATE TABLE` et
avant la réconciliation des status :

```js
// ─── Migration additive : planification cross-session ──────────────────────
const planCols = db.prepare("PRAGMA table_info(plans)").all().map((c) => c.name);
if (!planCols.includes("created_by")) {
  db.exec("ALTER TABLE plans ADD COLUMN created_by TEXT NOT NULL DEFAULT 'human'");
}
if (!planCols.includes("approved_at")) {
  db.exec("ALTER TABLE plans ADD COLUMN approved_at INTEGER");
  // Backfill : tous les plans existants sont des plans humains → auto-approuvés
  // (sinon mf_next_step cesserait subitement de les servir).
  db.prepare("UPDATE plans SET approved_at = created_at WHERE approved_at IS NULL").run();
}
```

> ⚠️ Le backfill est **critique** : sans lui, tous les plans déjà en base
> deviennent non-servables après déploiement.

---

## 4. Couche DB (`src/db.mjs`)

### 4.1 `createPlan` — tracer l'origine + l'approbation

```js
export function createPlan({ project, title, created_by = "human" }) {
  initDb();
  if (!VALID_PROJECTS.includes(project)) {
    throw new Error(`project invalide: '${project}' (attendu: ${VALID_PROJECTS.join("|")}).`);
  }
  if (!title || !title.trim()) throw new Error("title requis.");
  const now = nowSec();
  // Plan humain (board) = auto-approuvé. Plan d'une session = en attente.
  const approved_at = created_by === "human" ? now : null;
  const result = db
    .prepare(
      `INSERT INTO plans (project, title, status, created_by, approved_at, created_at, updated_at)
       VALUES (?, ?, 'active', ?, ?, ?, ?)`,
    )
    .run(project, title.trim(), created_by, approved_at, now, now);
  return { id: Number(result.lastInsertRowid), project, title: title.trim(), created_by, approved_at };
}
```

### 4.2 `createPlanForSession` — plan + tâches atomique

```js
// Crée un plan + toutes ses tâches en une transaction. Utilisé par l'outil
// MCP mf_plan. created_by = la session appelante → plan en attente d'approbation.
export function createPlanForSession({ project, title, tasks, created_by }) {
  initDb();
  if (!Array.isArray(tasks) || tasks.length === 0) {
    throw new Error("tasks requis (au moins une étape).");
  }
  db.exec("BEGIN");
  try {
    const plan = createPlan({ project, title, created_by });
    const created = [];
    for (const t of tasks) {
      // addTask valide title/instructions et refuse un plan non-writable.
      created.push(addTask({ plan_id: plan.id, title: t.title, instructions: t.instructions }));
    }
    db.exec("COMMIT");
    return { plan, tasks: created };
  } catch (err) {
    db.exec("ROLLBACK");
    throw err;
  }
}
```

### 4.3 `approvePlan` — la coche humaine

```js
// Approuve un plan créé par une session (board → "Approuver"). Idempotent.
export function approvePlan({ id }) {
  initDb();
  const p = db.prepare("SELECT id, approved_at FROM plans WHERE id = ?").get(id);
  if (!p) throw new Error(`Plan #${id} introuvable.`);
  if (p.approved_at) return { id, approved: true, already: true };
  const now = nowSec();
  db.prepare("UPDATE plans SET approved_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
  return { id, approved: true, already: false };
}
```

### 4.4 `nextStep` — ne servir que les plans approuvés

Dans la requête de **promotion** d'une tâche `pending` (≈ ligne 360),
ajouter le filtre sur l'approbation du plan :

```sql
-- ... JOIN plans p ON p.id = t.plan_id
WHERE p.project = ?
  AND p.approved_at IS NOT NULL        -- ← AJOUT : ignore les plans en attente
  AND t.status = 'pending'
ORDER BY t.plan_id ASC, t.position ASC
LIMIT 1
```

> La détection de tâche déjà active (≈ ligne 320) n'a pas besoin de changer :
> un plan en attente n'a jamais de tâche active.

---

## 5. Outil MCP `mf_plan` (`src/server.mjs`)

### 5.1 Définition (ajouter à `TOOLS`)

```js
{
  name: "mf_plan",
  description:
    "Créer un plan d'étapes pour une autre session (ou la tienne). Le plan est " +
    "déposé EN ATTENTE D'APPROBATION humaine au board ; une fois approuvé par un " +
    "humain, la session cible le reçoit étape par étape via mf_next_step. " +
    "À utiliser quand tu veux faire exécuter un travail séquencé par une autre " +
    "session, au lieu de juste poser une question (mf_ask).",
  inputSchema: {
    type: "object",
    properties: {
      project: {
        type: "string",
        enum: BACKEND_KEYS,
        description: `Session cible. ${backendDescriptions()}.`,
      },
      title: { type: "string", description: "Titre court du plan." },
      tasks: {
        type: "array",
        minItems: 1,
        description: "Étapes ordonnées. Chaque étape a un title et des instructions.",
        items: {
          type: "object",
          properties: {
            title: { type: "string", description: "Titre court de l'étape." },
            instructions: {
              type: "string",
              description: "Instructions complètes (markdown), pointeurs file:line, critère de done.",
            },
          },
          required: ["title", "instructions"],
        },
      },
    },
    required: ["project", "title", "tasks"],
  },
}
```

> `BACKEND_KEYS` / `backendDescriptions` sont déjà importés depuis
> [`src/backends.mjs`](../src/backends.mjs).

### 5.2 Import + handler

```js
// en-tête : ajouter createPlanForSession à l'import depuis ./db.mjs
import { /* … */ createPlanForSession } from "./db.mjs";
```

```js
// dans le switch(name) de CallToolRequestSchema
case "mf_plan": {
  if (!args.project || !args.title || !Array.isArray(args.tasks)) {
    throw new Error("Champs requis: project, title, tasks[].");
  }
  const r = createPlanForSession({
    project: args.project,
    title: args.title,
    tasks: args.tasks,
    created_by: SESSION,        // ← provenance = session appelante
  });
  return textOk(
    `Plan #${r.plan.id} créé pour '${args.project}' avec ${r.tasks.length} étape(s), ` +
    `par '${SESSION}'. EN ATTENTE D'APPROBATION : un humain doit l'approuver dans ` +
    `le board (http://127.0.0.1:7878) avant que '${args.project}' ne le reçoive via ` +
    `mf_next_step.`,
  );
}
```

> **Note doc :** ça porte le nombre d'outils MCP de 8 à 9. Mettre à jour
> [SETUP.md](../SETUP.md) §2 et §7.

---

## 6. Board / web (`src/web.mjs` + `public/index.html`)

### 6.1 Endpoint d'approbation (`web.mjs`)

```js
// POST /api/plans/:id/approve — coche humaine d'approbation d'un plan session.
const planApproveMatch = pathname.match(/^\/api\/plans\/(\d+)\/approve$/);
if (planApproveMatch && req.method === "POST") {
  try {
    const id = Number(planApproveMatch[1]);
    const result = approvePlan({ id });
    return sendJson(res, 200, { ok: true, ...result });
  } catch (err) {
    return sendJson(res, 400, { error: err.message });
  }
}
```
(et ajouter `approvePlan` à l'import depuis `./db.mjs`.)

`getAllPlans()` fait un `SELECT *` → `created_by` et `approved_at` remontent
déjà dans `/api/plans`. Rien à changer côté lecture.

### 6.2 UI (`index.html`)

Dans `renderPlans()` (rendu d'une carte plan) :

- **Pill de provenance** quand `plan.created_by !== 'human'` :
  `créé par <created_by>`.
- **Bandeau d'approbation** quand `plan.approved_at == null` :
  texte « En attente d'approbation — créé par `<created_by>` » +
  boutons **Approuver** (`data-action="approve-plan"`) et
  **Rejeter** (réutilise `delete-plan`).
- Style : réutiliser le liseré `--dir-<created_by>` pour rappeler la source.

Handler de clic (à côté des autres `data-action`) :

```js
if (action === 'approve-plan') {
  await fetch(`/api/plans/${planId}/approve`, { method: 'POST' });
  await fullRefresh(true);
  return;
}
```

> Optionnel : un onglet/filtre « À approuver » qui ne montre que les plans
> `approved_at == null`, pour que l'humain les voie tout de suite.

---

## 7. Tests (`test/plans.mjs`)

Ajouter une section dédiée :

1. **Création session = en attente :**
   `createPlanForSession({project:'app', title, tasks:[…], created_by:'panel'})`
   → plan `approved_at == null`.
2. **Non servi tant que non approuvé :** insérer ce plan, puis
   `nextStep({for_session:'app'})` → `kind === 'empty'`.
3. **Approbation débloque :** `approvePlan({id})` puis
   `nextStep({for_session:'app'})` → `kind === 'current'`, sert la 1re étape.
4. **Plan humain auto-approuvé :** `createPlan({project:'app', title})` (sans
   `created_by`) → `approved_at != null`, servi immédiatement.
5. **Atomicité :** `createPlanForSession` avec une tâche invalide (instructions
   vides) → throw **et** aucun plan créé (rollback).

Penser aussi à mettre à jour `test/smoke.mjs` si le compte d'outils y est
asserté.

---

## 8. Optionnel — notifier la cible

À la fin du handler `mf_plan`, déposer un message inbox pour que la cible voie
qu'un plan l'attend (sans attendre qu'elle poll `mf_next_step`) :

```js
ask({
  from: SESSION,
  to: args.project,
  subject: `Plan #${r.plan.id} créé pour toi (en attente d'approbation)`,
  body: `J'ai préparé un plan « ${args.title} » (${r.tasks.length} étapes). ` +
        `Il sera dans ta file dès qu'un humain l'aura approuvé au board.`,
});
```

---

## 9. Évolutions possibles (hors scope initial)

- **Allowlist de planification** : table/const `CAN_PLAN_FOR = { panel: ['app','mobile','coolcare'], … }` si on veut restreindre qui cible qui.
- **Rejet explicite** vs simple suppression (statut `rejected` + raison).
- **Auto-approbation par règle** (ex. un plan que la session crée *pour
  elle-même* pourrait être auto-approuvé) — à décider selon l'usage.
- **`mf_plan_add_task`** pour étendre un plan existant depuis une session.

---

## 10. Checklist de déploiement

- [ ] `src/db.mjs` : migration colonnes + backfill, `createPlan`, `createPlanForSession`, `approvePlan`, filtre `approved_at` dans `nextStep`.
- [ ] `src/server.mjs` : import + outil `mf_plan` + handler.
- [ ] `src/web.mjs` : import `approvePlan` + endpoint `/api/plans/:id/approve`.
- [ ] `public/index.html` : pill provenance + bandeau/boutons d'approbation + handler de clic.
- [ ] `test/plans.mjs` (+ `smoke.mjs`) : nouveaux cas, suite verte.
- [ ] `SETUP.md` §2 et §7 : passer de 8 à 9 outils, documenter `mf_plan`.
- [ ] **Redémarrer `web.mjs`** (kill → systemd `Restart=always` recharge le code) pour le nouvel endpoint + l'UI.
- [ ] **Reconnecter chaque session Claude** (relance / reconnect MCP) : l'outil `mf_plan` n'apparaît qu'au handshake `initialize` — contrainte inhérente à MCP, cf. [SETUP.md](../SETUP.md) « Ajouter un backend / une session ».

---

## 11. Effort estimé

| Lot | Contenu | Taille |
|---|---|---|
| **A — DB** | migration + 3 fonctions + filtre nextStep | ~1 h |
| **B — MCP** | outil `mf_plan` + handler | ~30 min |
| **C — Board** | endpoint approve + UI provenance/approbation | ~1–1,5 h |
| **D — Tests + doc** | cas plans/smoke + SETUP | ~45 min |

Lots A→B→C→D séquentiels ; A et B suffisent pour un MVP testable en CLI (sans
UI d'approbation, on approuve via un `UPDATE` SQL manuel ou un appel curl).
