brainclaw 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-cloud.js +121 -13
  3. package/dist/cli/register-code-map.js +9 -2
  4. package/dist/commands/cloud.js +534 -39
  5. package/dist/commands/code-map.js +119 -2
  6. package/dist/commands/mcp-catalog.js +46 -0
  7. package/dist/commands/mcp.js +54 -2
  8. package/dist/core/code-map/backend.js +158 -1
  9. package/dist/core/code-map/export.js +212 -0
  10. package/dist/core/code-map/freshness.js +3 -2
  11. package/dist/core/code-map/impact.js +377 -0
  12. package/dist/core/code-map/indexes.js +27 -3
  13. package/dist/core/code-map/lang/typescript/config.js +271 -0
  14. package/dist/core/code-map/lang/typescript/index.js +20 -4
  15. package/dist/core/code-map/query.js +76 -13
  16. package/dist/core/code-map/refresh.js +0 -0
  17. package/dist/core/code-map/resolve.js +1 -0
  18. package/dist/core/code-map/types.js +15 -0
  19. package/dist/core/federation-emit.js +283 -0
  20. package/dist/core/federation-grant-transport.js +196 -0
  21. package/dist/core/federation-grant.js +223 -0
  22. package/dist/core/federation-keyring.js +39 -0
  23. package/dist/core/federation-opaque-ids.js +111 -0
  24. package/dist/core/federation-outbox-v2.js +36 -2
  25. package/dist/core/federation-pairing.js +87 -12
  26. package/dist/core/federation-pull.js +523 -0
  27. package/dist/core/federation-push.js +287 -0
  28. package/dist/core/federation-rotation.js +124 -0
  29. package/dist/core/federation-state.js +81 -6
  30. package/dist/core/protocol-tool-policy.js +3 -0
  31. package/dist/core/worktree.js +89 -2
  32. package/dist/facts.js +14 -11
  33. package/dist/facts.json +13 -10
  34. package/docs/cli.md +8 -0
  35. package/docs/code-map.md +24 -1
  36. package/docs/design/federation-onboarding-usecases.md +254 -0
  37. package/docs/design/pairing-v3-brief.md +80 -0
  38. package/docs/integrations/mcp.md +5 -2
  39. package/docs/mcp-schema-changelog.md +11 -1
  40. package/package.json +1 -1
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Fédération v2 — TRANSPORT : drainer l'outbox vers le cloud.
3
+ *
4
+ * ── POURQUOI CE MODULE EST SÉPARÉ DE L'ÉMISSION ───────────────────────────────
5
+ * `federation-emit.ts` scelle et met en file ; celui-ci envoie. La séparation n'est pas
6
+ * cosmétique : une file qui saurait aussi parler au réseau serait un SECOND chemin par
7
+ * lequel un objet pourrait sortir sans passer par les trois filets du projecteur. Ici, tout
8
+ * ce qui part a déjà été scellé — ce module ne voit que des `sealed` opaques et ne peut donc
9
+ * pas divulguer de clair, même par erreur de programmation.
10
+ *
11
+ * ── CE QU'IL FAIT DE L'ÉCHEC ──────────────────────────────────────────────────
12
+ * Une entrée qui échoue RESTE en attente, avec son erreur enregistrée. Elle n'est ni
13
+ * supprimée ni déplacée en `conflict` : un échec réseau n'est pas un conflit de révision, et
14
+ * les confondre ferait disparaître de la file une opération jamais émise.
15
+ *
16
+ * Seul un 409 fait passer en `conflict` — c'est le cas où le cloud dit « ta base_rev est
17
+ * périmée », donc un désaccord d'état qu'un renvoi ne résoudra pas.
18
+ */
19
+ import crypto from 'node:crypto';
20
+ import { list, transition } from './federation-outbox-v2.js';
21
+ import { loadConnectionState } from './federation-state.js';
22
+ import { loadAgentSigningKey } from './agent-registry.js';
23
+ import { logger } from './logger.js';
24
+ /**
25
+ * Met une entrée d'outbox à la forme que le cloud attend RÉELLEMENT.
26
+ *
27
+ * ── POURQUOI CETTE FONCTION EXISTE, ET CE QU'ELLE A COÛTÉ ────────────────────
28
+ * La première version envoyait `{ envelope, key_epoch, base_rev }` — la forme que je
29
+ * SUPPOSAIS. Le cloud attend des champs À PLAT : id, entity_kind, entity_id, rev,
30
+ * sealed_b64, content_hash, meta, et quatre champs d'origine. Mon test de bout en bout
31
+ * n'a rien vu : il utilisait un `fetch` simulé, donc il validait mon hypothèse de l'API
32
+ * contre elle-même.
33
+ *
34
+ * C'est la même leçon que d'habitude, à un endroit nouveau : un contrat inter-services ne
35
+ * se vérifie que contre le service, jamais contre l'idée qu'on s'en fait.
36
+ *
37
+ * `sealed` est encodé en base64 d'un JSON canonique : le cloud le stocke sans jamais
38
+ * l'ouvrir — il n'en a pas la clé.
39
+ */
40
+ /** En-têtes communs : type, idempotence, et porteur si le déploiement l'exige. */
41
+ function authHeaders(idempotencyKey, apiKey) {
42
+ const headers = {
43
+ 'content-type': 'application/json',
44
+ // La clé d'idempotence voyage AUSSI en en-tête : le cloud doit pouvoir dédoublonner
45
+ // sans ouvrir le corps, qu'il ne peut de toute façon pas lire.
46
+ 'idempotency-key': idempotencyKey,
47
+ };
48
+ if (apiKey)
49
+ headers['authorization'] = `Bearer ${apiKey}`;
50
+ return headers;
51
+ }
52
+ /**
53
+ * Charge SIGNÉE DU TRANSPORT — reconstruite exactement comme le cloud la reconstruit.
54
+ *
55
+ * L'ordre des clés compte : le cloud fait `JSON.stringify` sur cet objet littéral, sans
56
+ * tri. Une clé déplacée produit une chaîne différente, donc un condensé différent, donc un
57
+ * refus SIG_PAYLOAD_HASH_MISMATCH que rien dans le message n'expliquerait.
58
+ */
59
+ function transportPayload(wire, baseRev) {
60
+ return JSON.stringify({
61
+ v: 1,
62
+ kind: 'brainclaw.federation.v2.envelope',
63
+ envelope_id: wire['id'],
64
+ project_id: wire['__project_id'],
65
+ entity_kind: wire['entity_kind'],
66
+ entity_id: wire['entity_id'],
67
+ rev: wire['rev'],
68
+ base_rev: baseRev,
69
+ content_hash: wire['content_hash'],
70
+ key_epoch: wire['key_epoch'],
71
+ is_tombstone: false,
72
+ });
73
+ }
74
+ /**
75
+ * Signe la charge de transport et renseigne les trois champs qui en dépendent.
76
+ *
77
+ * ── POURQUOI LE TRANSPORT SIGNE, ALORS QUE L'ENVELOPPE EST DÉJÀ SIGNÉE ───────
78
+ * Ce sont DEUX garanties distinctes. La signature d'enveloppe (RFC) lie meta ‖ sealed ‖
79
+ * key_epoch : elle dit « ce contenu vient de cet auteur ». La signature de transport lie
80
+ * envelope_id, rev, base_rev et content_hash : elle dit « cette opération-ci s'applique à
81
+ * CETTE révision », ce que la première ne peut pas dire — ces champs n'existent pas encore
82
+ * au moment de sceller.
83
+ *
84
+ * ── ET C'EST CE QUI DÉBLOQUE LE RECALAGE SUR 409 ────────────────────────────
85
+ * J'avais d'abord jugé les deux contrats incompatibles (dec#160 §6), parce que `base_rev`
86
+ * peut changer au réessai et invaliderait une signature calculée à l'émission. Faire signer
87
+ * le TRANSPORT lève l'objection : il resigne simplement avec la nouvelle valeur.
88
+ *
89
+ * Cette clé Ed25519 SIGNE, elle ne déchiffre rien : la propriété « ce module ne peut pas
90
+ * divulguer de clair » tient toujours.
91
+ */
92
+ function signTransport(wire, baseRev, identityPem) {
93
+ const payload = Buffer.from(transportPayload(wire, baseRev), 'utf-8');
94
+ const signature = crypto.sign(null, payload, crypto.createPrivateKey(identityPem));
95
+ const body = { ...wire, base_rev: baseRev };
96
+ delete body['__project_id'];
97
+ return {
98
+ ...body,
99
+ origin_sig: signature.toString('base64'),
100
+ origin_sig_payload_hash: crypto.createHash('sha256').update(payload).digest('hex'),
101
+ };
102
+ }
103
+ function toWireBody(entry) {
104
+ const env = entry.sealed;
105
+ const meta = env.meta;
106
+ const transport = (meta['transport'] ?? {});
107
+ // ── L'EMPREINTE PORTE SUR CE QUI EST RÉELLEMENT ENVOYÉ ──────────────────────
108
+ //
109
+ // Le cloud recalcule SHA-256 sur les octets DÉCODÉS de `sealed_b64`, en hexadécimal, et
110
+ // refuse tout écart (CONTENT_HASH_MISMATCH). Le `content_hash` du core est calculé
111
+ // autrement — base64url sur une autre sérialisation — donc le réutiliser tel quel
112
+ // échouait systématiquement.
113
+ //
114
+ // Le calculer ICI, sur les octets exacts qu'on encode, rend l'accord vrai PAR
115
+ // CONSTRUCTION plutôt que par coïncidence de conventions. Deux sérialisations qui
116
+ // doivent produire le même condensé sont un pari ; hacher ce qu'on envoie n'en est pas un.
117
+ const sealedBytes = Buffer.from(JSON.stringify(env.sealed), 'utf-8');
118
+ const contentHashHex = crypto.createHash('sha256').update(sealedBytes).digest('hex');
119
+ return {
120
+ id: String(transport['idempotency_key'] ?? entry.idempotency_key),
121
+ entity_kind: meta['kind'],
122
+ entity_id: meta['id_opaque'],
123
+ rev: String(meta['base_rev'] ?? entry.base_rev ?? 0),
124
+ // Aucune tête connue au premier envoi : le cloud l'annonce dans son 409 et l'appelant
125
+ // se recale une fois. Suivre cette tête localement dupliquerait un état dont le cloud
126
+ // est déjà l'autorité.
127
+ base_rev: null,
128
+ sealed_b64: sealedBytes.toString('base64'),
129
+ key_epoch: env.key_epoch,
130
+ content_hash: contentHashHex,
131
+ idempotency_key: String(transport['idempotency_key'] ?? entry.idempotency_key),
132
+ // `key_id` de l'enveloppe EST l'empreinte du signataire (cf. federation-emit).
133
+ // L'agent d'origine voyage à part : le cloud vérifie l'un ET l'autre.
134
+ origin_agent_id: entry.origin_agent_id ?? env.origin_sig.key_id,
135
+ // Renseignés par `signTransport` : ils dépendent de `base_rev`, connu seulement ici.
136
+ origin_sig: '',
137
+ origin_sig_payload_hash: '',
138
+ origin_signer_fingerprint: env.origin_sig.key_id,
139
+ meta,
140
+ // ── L'ENVELOPPE SIGNÉE, VERBATIM (dec#162) ──────────────────────────────────
141
+ //
142
+ // La signature de TRANSPORT ci-dessus prouve « cette opération s'applique à cette
143
+ // révision » ; elle ne prouve RIEN sur l'auteur du contenu. La signature d'AUTEUR
144
+ // (origin_sig sur meta‖sealed‖key_epoch, cf. buildEnvelope) est la seule qui le fasse —
145
+ // et `toWireBody` l'écrasait jusqu'ici en réutilisant le champ `origin_sig` pour le
146
+ // transport. Un lecteur (pull) ne pouvait donc pas vérifier qui a écrit l'enveloppe.
147
+ //
148
+ // On transporte l'enveloppe complète SANS LA TOUCHER. Le cloud la stocke telle quelle et
149
+ // la rend au pull ; le vérificateur y retrouve `origin_sig.value` d'auteur et
150
+ // recanonicalise meta/sealed lui-même (federation-inbound). Aucune donnée NOUVELLE ne
151
+ // fuit : `meta` partait déjà en clair au relais (ligne ci-dessus) — on ne fait que
152
+ // PERSISTER ce qui transitait déjà. Le champ n'entre pas dans la signature de transport :
153
+ // son intégrité est portée par la signature d'auteur qu'il contient.
154
+ envelope_json: JSON.stringify(entry.sealed),
155
+ };
156
+ }
157
+ /**
158
+ * Envoie les enveloppes en attente.
159
+ *
160
+ * REFUSE plutôt que de deviner : sans appairage actif, sans URL, la fonction lève. Pousser
161
+ * vers une adresse devinée enverrait des enveloppes chiffrées à un tiers — inintelligibles
162
+ * pour lui, mais c'est une fuite de métadonnées et de trafic, pas un non-événement.
163
+ */
164
+ export async function pushPending(options = {}) {
165
+ const cwd = options.cwd ?? process.cwd();
166
+ const doFetch = options.fetchImpl ?? fetch;
167
+ const result = { attempted: 0, sent: 0, conflicts: 0, failed: 0, errors: [] };
168
+ const state = loadConnectionState(cwd);
169
+ if (!state || state.enrollment.stage !== 'active') {
170
+ throw new Error('Aucun appairage actif : rien n\'est envoyé tant que l\'appairage n\'est pas confirmé localement.');
171
+ }
172
+ // MANQUE MESURÉ (2026-08-09) : `FederationConnectionState` ne persiste AUCUNE adresse de
173
+ // cloud — ni à la racine, ni dans `sync`. L'appairage prend `--url` puis l'oublie. Chaque
174
+ // commande doit donc la repasser. C'est un défaut d'ergonomie à corriger côté appairage,
175
+ // pas ici : deviner une adresse enverrait le trafic d'un projet à un tiers — illisible
176
+ // pour lui, mais une fuite de métadonnées et de trafic n'est pas un non-événement.
177
+ const base = (options.url ?? '').replace(/\/+$/, '');
178
+ if (!base) {
179
+ throw new Error('Adresse du cloud inconnue : passez --url. Elle n\'est pas conservée par l\'appairage ' +
180
+ '(état de connexion sans champ d\'URL), et aucune adresse n\'est devinée.');
181
+ }
182
+ const pending = list('pending', cwd);
183
+ // File vide = rien à signer : sortir AVANT de résoudre l'identité. Le signataire est
184
+ // déduit de la première entrée pending ; le chercher sur une file vide transformait
185
+ // « tout est déjà parti » en erreur d'identité — vécu le 2026-08-10, juste après un
186
+ // envoi complet dont il ne restait que des conflits.
187
+ if (pending.length === 0)
188
+ return result;
189
+ // L'identité SIGNATAIRE du transport : celle de l'agent qui a produit les enveloppes.
190
+ // Elle est portée par l'entrée d'outbox, donc le transport n'a pas à deviner qui signe.
191
+ const agentId = options.agentId ?? pending[0]?.origin_agent_id;
192
+ const identity = agentId ? loadAgentSigningKey(agentId) : undefined;
193
+ if (!identity) {
194
+ throw new Error('Identité de signature introuvable : le cloud vérifie une signature de TRANSPORT ' +
195
+ 'liant envelope_id, rev et base_rev. Sans elle, chaque envoi est refusé en 422.');
196
+ }
197
+ const identityPem = identity.privateKeyPem;
198
+ const batch = options.limit ? pending.slice(0, options.limit) : pending;
199
+ result.attempted = batch.length;
200
+ if (options.dryRun)
201
+ return result;
202
+ const endpoint = `${base}/api/v1/projects/${state.cloud_project_id}/projection/envelopes`;
203
+ for (const entry of batch) {
204
+ try {
205
+ const wire = { ...toWireBody(entry), __project_id: state.cloud_project_id };
206
+ let signed = signTransport(wire, null, identityPem);
207
+ let res = await doFetch(endpoint, {
208
+ method: 'POST',
209
+ headers: authHeaders(entry.idempotency_key, options.apiKey),
210
+ body: JSON.stringify(signed),
211
+ });
212
+ // ── RECALAGE SUR 409, UNE SEULE FOIS ──────────────────────────────────
213
+ //
214
+ // Le cloud applique une concurrence optimiste : `base_rev` doit désigner la tête
215
+ // courante. Un émetteur qui n'a jamais poussé ne CONNAÎT pas cette tête — et la
216
+ // suivre localement dupliquerait un état dont le cloud est déjà l'autorité.
217
+ //
218
+ // Le refus la contient (`expected_base_rev`) : on renvoie donc une fois avec la
219
+ // valeur annoncée. UNE SEULE, délibérément : boucler transformerait un désaccord
220
+ // réel en écrasement silencieux du travail d'un autre appareil.
221
+ if (res.status === 409) {
222
+ const detail = (await res.clone().json().catch(() => ({})));
223
+ // `current_head_rev` est le nom que le serveur DÉPLOYÉ répond (projection.ts,
224
+ // REV_CONFLICT). Les deux autres sont des noms historiques gardés en repli.
225
+ // Dérive constatée le 2026-08-10 : le client lisait `expected_base_rev` sur une
226
+ // réponse qui ne l'a jamais porté — le recalage ne se déclenchait donc JAMAIS et
227
+ // chaque mise à jour finissait en conflit. Le test unitaire rejoue depuis la
228
+ // forme de réponse RÉELLE du serveur ; leçon dec#160/162, un contrat inter-
229
+ // services ne se vérifie que contre le service.
230
+ const expected = detail['current_head_rev'] ?? detail['expected_base_rev'] ?? detail['expected'];
231
+ if (typeof expected === 'string' || typeof expected === 'number') {
232
+ // On RESIGNE avec la nouvelle base_rev : c'est précisément ce que la signature
233
+ // au niveau du transport rend possible, et qu'une signature figée à l'émission
234
+ // interdisait.
235
+ signed = signTransport(wire, String(expected), identityPem);
236
+ res = await doFetch(endpoint, {
237
+ method: 'POST',
238
+ headers: authHeaders(entry.idempotency_key, options.apiKey),
239
+ body: JSON.stringify(signed),
240
+ });
241
+ }
242
+ }
243
+ if (res.ok || res.status === 202) {
244
+ transition(entry.idempotency_key, 'pending', 'synced', cwd);
245
+ result.sent += 1;
246
+ continue;
247
+ }
248
+ if (res.status === 409) {
249
+ // Désaccord de révision : un renvoi à l'identique échouera pareil. L'entrée passe
250
+ // en `conflict` pour être VUE, pas retentée en boucle.
251
+ transition(entry.idempotency_key, 'pending', 'conflict', cwd, (e) => ({
252
+ ...e,
253
+ last_error: `409 conflit de révision (base_rev=${entry.base_rev})`,
254
+ }));
255
+ result.conflicts += 1;
256
+ result.errors.push({ idempotency_key: entry.idempotency_key, status: 409, reason: 'conflit de révision' });
257
+ continue;
258
+ }
259
+ const body = await res.text();
260
+ result.failed += 1;
261
+ result.errors.push({
262
+ idempotency_key: entry.idempotency_key,
263
+ status: res.status,
264
+ reason: body.slice(0, 200),
265
+ });
266
+ // RESTE en attente : un refus temporaire ne doit pas faire disparaître l'opération.
267
+ transition(entry.idempotency_key, 'pending', 'pending', cwd, (e) => ({
268
+ ...e,
269
+ attempts: e.attempts + 1,
270
+ last_error: `HTTP ${res.status}`,
271
+ }));
272
+ }
273
+ catch (err) {
274
+ const reason = err instanceof Error ? err.message : String(err);
275
+ result.failed += 1;
276
+ result.errors.push({ idempotency_key: entry.idempotency_key, reason });
277
+ transition(entry.idempotency_key, 'pending', 'pending', cwd, (e) => ({
278
+ ...e,
279
+ attempts: e.attempts + 1,
280
+ last_error: reason,
281
+ }));
282
+ logger.warn(`Envoi échoué (${entry.idempotency_key}) : ${reason}`);
283
+ }
284
+ }
285
+ return result;
286
+ }
287
+ //# sourceMappingURL=federation-push.js.map
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Rotation d'epoch — retirer la LECTURE FUTURE à un appareil (pln#658, dec#163).
3
+ *
4
+ * ── CE QUE LA ROTATION FAIT, ET CE QU'ELLE NE PEUT PAS FAIRE ──────────────────
5
+ * Elle crée un epoch N+1 dont le révoqué n'a pas la clé, et fait basculer les écritures
6
+ * dessus. À partir du cutover, il ne lit plus RIEN de nouveau.
7
+ *
8
+ * Elle ne lui retire PAS ce qu'il détient déjà. C'est cryptographiquement impossible : une
9
+ * clé copiée sur une machine y reste. Le prétendre serait mentir, et dec#163 §3 l'interdit
10
+ * explicitement. La révocation est FORWARD-ONLY, et c'est dit à l'opérateur en toutes
11
+ * lettres au moment où il tourne la clé — pas dans une note de bas de page.
12
+ *
13
+ * ── LE QUORUM EST APPLIQUÉ ICI, ET SEULEMENT ICI (dec#163 §4) ─────────────────
14
+ * « Émettre au-delà du premier epoch » = créer un epoch N+1. C'est le moment exact où la
15
+ * perte d'une machine cesse d'être théorique : après rotation, l'ancien epoch ne sert plus
16
+ * qu'à relire le passé, et si personne d'autre ne détient le nouveau, une panne de disque
17
+ * emporte tout le futur. `recoveryReadiness` était RAPPORTÉ et jamais bloquant (mesuré) —
18
+ * il devient une condition.
19
+ *
20
+ * En solo, l'opérateur peut passer outre, mais son consentement est PERSISTÉ : on ne
21
+ * demande pas deux fois, et surtout on garde la trace qu'il a été demandé.
22
+ */
23
+ import crypto from 'node:crypto';
24
+ import { loadConnectionState, saveConnectionState, recoveryReadiness, } from './federation-state.js';
25
+ import { storeEpochPrivateKey, epochPublicKey, heldEpochs } from './federation-keyring.js';
26
+ export const FORWARD_ONLY_NOTICE = "La rotation ne retire à personne ce qu'il détient DÉJÀ : un appareil révoqué peut " +
27
+ "toujours relire ce qui a été scellé avant le cutover. Elle ferme la lecture du FUTUR, " +
28
+ 'pas celle du passé — aucune cryptographie ne peut faire autrement.';
29
+ const SOLO_CONSENT_STATEMENT = "Je comprends que la perte de cette machine signifie la perte définitive de l'historique " +
30
+ 'scellé : aucune restauration côté cloud ne le ramènera.';
31
+ export function soloConsentStatement() {
32
+ return SOLO_CONSENT_STATEMENT;
33
+ }
34
+ function readConsent(state) {
35
+ return state.solo_recovery_consent;
36
+ }
37
+ /**
38
+ * Enregistre le consentement solo. Idempotent : réaccepter ne réécrit pas la date d'origine,
39
+ * parce que la question « quand cette personne a-t-elle compris le risque ? » a UNE réponse.
40
+ */
41
+ export function acceptSoloRecoveryRisk(cwd) {
42
+ const state = loadConnectionState(cwd ?? process.cwd());
43
+ if (!state)
44
+ throw new Error('Aucun appairage local : rien à consentir.');
45
+ const existing = readConsent(state);
46
+ if (existing)
47
+ return existing;
48
+ const consent = {
49
+ accepted_at: new Date().toISOString(),
50
+ statement: SOLO_CONSENT_STATEMENT,
51
+ };
52
+ saveConnectionState({ ...state, ...{ solo_recovery_consent: consent } }, cwd ?? process.cwd());
53
+ return consent;
54
+ }
55
+ /**
56
+ * Fait tourner l'epoch : crée N+1 localement et bascule les écritures dessus.
57
+ *
58
+ * NE REMET RIEN AUX AUTRES — c'est délibérément une seconde étape (`cloud grant`). Coupler
59
+ * les deux ferait qu'un échec de remise laisserait un cutover à moitié fait : les écritures
60
+ * seraient déjà passées sous N+1 pendant que les lecteurs légitimes n'auraient pas la clé.
61
+ * Le résultat NOMME donc les destinataires à re-servir.
62
+ */
63
+ export function rotateEpoch(options = {}) {
64
+ const cwd = options.cwd ?? process.cwd();
65
+ const state = loadConnectionState(cwd);
66
+ if (!state) {
67
+ return {
68
+ ok: false, reason: 'no_pairing',
69
+ detail: 'Aucun appairage local.',
70
+ remedy: 'Appairer cet appareil avant toute rotation (`brainclaw cloud connect`).',
71
+ };
72
+ }
73
+ const held = heldEpochs(state.cloud_project_id, options.home);
74
+ const current = state.keys?.current_epoch ?? 0;
75
+ if (current <= 0 || !held.includes(current)) {
76
+ return {
77
+ ok: false, reason: 'not_holder',
78
+ detail: `Cet appareil ne détient pas l'epoch courant (${current}) — il ne peut pas en produire le suivant.`,
79
+ remedy: "Se faire remettre l'epoch courant par un détenteur (`brainclaw cloud grant`) avant de tourner.",
80
+ };
81
+ }
82
+ // ── LE QUORUM, APPLIQUÉ (dec#163 §4) ────────────────────────────────────────
83
+ const readiness = recoveryReadiness(state);
84
+ const consent = readConsent(state);
85
+ if (!readiness.ready && !consent && !options.force) {
86
+ return {
87
+ ok: false, reason: 'recovery_quorum',
88
+ detail: `${readiness.reason ?? 'Quorum de récupération non atteint.'} ` +
89
+ "Tourner l'epoch maintenant ferait dépendre TOUT le futur du projet de cette seule machine.",
90
+ remedy: "Appairer un second appareil de récupération, OU accepter explicitement le risque " +
91
+ '(`brainclaw cloud accept-solo-risk`), ce qui sera consigné avec sa date.',
92
+ };
93
+ }
94
+ const next = current + 1;
95
+ // Ne jamais écraser : si N+1 existe déjà, c'est qu'une rotation a déjà eu lieu (reprise
96
+ // après interruption). On la constate au lieu d'en fabriquer une seconde.
97
+ const already = epochPublicKey(state.cloud_project_id, next, options.home);
98
+ if (!already) {
99
+ const generated = crypto.generateKeyPairSync('x25519');
100
+ storeEpochPrivateKey(state.cloud_project_id, next, generated.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), options.home);
101
+ }
102
+ const materialized = epochPublicKey(state.cloud_project_id, next, options.home);
103
+ if (!materialized) {
104
+ // Vérifier APRÈS écriture : basculer les écritures sur une clé qu'on ne relit pas
105
+ // produirait des enveloppes que PERSONNE ne peut ouvrir, y compris nous.
106
+ throw new Error(`Epoch ${next} écrit mais non relisible — le cutover est ANNULÉ pour ne pas sceller ` +
107
+ 'sous une clé fantôme.');
108
+ }
109
+ // CUTOVER : les écritures suivantes scellent sous N+1 (emitProjections lit current_epoch).
110
+ const readable = heldEpochs(state.cloud_project_id, options.home);
111
+ saveConnectionState({
112
+ ...state,
113
+ keys: { ...(state.keys ?? { current_epoch: 0, known_epochs: [] }), current_epoch: next, known_epochs: readable },
114
+ }, cwd);
115
+ return {
116
+ ok: true,
117
+ previous_epoch: current,
118
+ new_epoch: next,
119
+ new_epoch_fingerprint: materialized.fingerprint,
120
+ readable_epochs: readable,
121
+ forward_only_notice: FORWARD_ONLY_NOTICE,
122
+ };
123
+ }
124
+ //# sourceMappingURL=federation-rotation.js.map
@@ -34,7 +34,9 @@ import { heldEpochs } from './federation-keyring.js';
34
34
  import { counters as outboxCounters } from './federation-outbox-v2.js';
35
35
  import { logger } from './logger.js';
36
36
  const CONNECTION_FILE = 'connection.json';
37
- export const FEDERATION_STATE_SCHEMA = 'brainclaw.federation-connection/v2';
37
+ export const FEDERATION_STATE_SCHEMA = 'brainclaw.federation-connection/v3';
38
+ /** Schéma v2 — singleton mono-agent. Lu en tolérance, jamais écrit (trp#1625). */
39
+ const FEDERATION_STATE_SCHEMA_V2 = 'brainclaw.federation-connection/v2';
38
40
  /** Nombre d'appareils de récupération exigés avant la première enveloppe (RFC §5.3). */
39
41
  export const REQUIRED_RECOVERY_DEVICES = 2;
40
42
  // ── Emplacement ───────────────────────────────────────────────────────────────
@@ -63,12 +65,73 @@ export function loadConnectionState(cwd = process.cwd()) {
63
65
  return undefined;
64
66
  }
65
67
  const state = raw;
66
- if (state.schema !== FEDERATION_STATE_SCHEMA || !state.cloud_project_id || !state.device?.device_id) {
67
- // Un schéma inconnu n'est PAS migré (dec#156) : la v1 est abandonnée, pas dépréciée.
68
- logger.warn(`État de connexion fédération ignoré : schéma '${String(state.schema)}' non reconnu (attendu ${FEDERATION_STATE_SCHEMA}).`);
68
+ const knownSchema = state.schema === FEDERATION_STATE_SCHEMA || state.schema === FEDERATION_STATE_SCHEMA_V2;
69
+ if (!knownSchema || !state.cloud_project_id || !state.device?.device_id) {
70
+ // Un schéma inconnu (v1) n'est PAS migré (dec#156) : abandonné, pas déprécié. Mais la
71
+ // v2 EST lue — c'est le format vivant sur les machines déjà appairées ; le refuser
72
+ // détruirait leur appairage au premier chargement après mise à jour.
73
+ logger.warn(`État de connexion fédération ignoré : schéma '${String(state.schema)}' non reconnu.`);
69
74
  return undefined;
70
75
  }
71
- return normalizeState(state, cwd);
76
+ return normalizeState(projectToV3(state), cwd);
77
+ }
78
+ /**
79
+ * Projette un état v2 (singleton) vers la forme v3 (liste de pairings) À LA LECTURE.
80
+ *
81
+ * Un v2 n'a pas de `pairings` ni de `cloud_url` ni d'agent mémorisé. On dérive un pairing
82
+ * unique de son `enrollment` — l'agent est marqué inconnu, faute de l'information : le
83
+ * singleton v2 ne l'a jamais stockée, et l'inventer serait mentir. Un état déjà v3 passe
84
+ * inchangé. Rien n'est réécrit sur disque ici : la conversion n'a lieu qu'en mémoire, et
85
+ * la prochaine ÉCRITURE (un appairage, une révocation) persistera la forme v3.
86
+ */
87
+ function projectToV3(state) {
88
+ if (Array.isArray(state.pairings)) {
89
+ return { ...state, schema: FEDERATION_STATE_SCHEMA };
90
+ }
91
+ const enrollment = state.enrollment ?? { stage: 'unpaired', updated_at: nowISO() };
92
+ const derived = {
93
+ agent_id: '(inconnu — appairé en v2)',
94
+ stage: enrollment.stage,
95
+ enrollment_id: enrollment.enrollment_id,
96
+ role: enrollment.role,
97
+ updated_at: enrollment.updated_at,
98
+ };
99
+ return {
100
+ ...state,
101
+ schema: FEDERATION_STATE_SCHEMA,
102
+ pairings: enrollment.enrollment_id || enrollment.stage !== 'unpaired' ? [derived] : [],
103
+ };
104
+ }
105
+ // ── Manipulation des pairings (v3) ─────────────────────────────────────────────
106
+ /** Le pairing d'un agent donné, ou `undefined`. */
107
+ export function pairingForAgent(state, agentId) {
108
+ return state.pairings.find((p) => p.agent_id === agentId);
109
+ }
110
+ /** Y a-t-il au moins un pairing actif ? Décide notamment la genèse d'epoch (premier appareil). */
111
+ export function hasActivePairing(state) {
112
+ return state.pairings.some((p) => p.stage === 'active');
113
+ }
114
+ /**
115
+ * Ajoute ou met à jour le pairing d'un agent, SANS toucher aux autres, et maintient le
116
+ * miroir `enrollment` sur le pairing touché. Retourne un nouvel état (immuable).
117
+ *
118
+ * C'est l'unique porte par laquelle un pairing entre ou change : centraliser le maintien
119
+ * du miroir ici évite qu'un écrivain oublie de le synchroniser et fasse diverger la vue
120
+ * mono-agent de la source de vérité.
121
+ */
122
+ export function upsertPairing(state, pairing) {
123
+ const others = state.pairings.filter((p) => p.agent_id !== pairing.agent_id);
124
+ return {
125
+ ...state,
126
+ pairings: [...others, pairing],
127
+ enrollment: {
128
+ stage: pairing.stage,
129
+ enrollment_id: pairing.enrollment_id,
130
+ role: pairing.role,
131
+ updated_at: pairing.updated_at,
132
+ },
133
+ updated_at: nowISO(),
134
+ };
72
135
  }
73
136
  /**
74
137
  * Réconcilie l'état déclaré avec le DISQUE.
@@ -154,11 +217,16 @@ function assertNoSecret(state) {
154
217
  */
155
218
  export function createConnectionState(params) {
156
219
  const now = nowISO();
220
+ const firstPairing = params.agentId
221
+ ? { agent_id: params.agentId, stage: 'pending', enrollment_id: params.enrollmentId, updated_at: now }
222
+ : undefined;
157
223
  return {
158
224
  schema: FEDERATION_STATE_SCHEMA,
159
225
  cloud_project_id: params.cloudProjectId,
226
+ cloud_url: params.cloudUrl,
160
227
  workspace_path: path.resolve(params.workspacePath ?? process.cwd()),
161
228
  enrollment: { stage: 'pending', enrollment_id: params.enrollmentId, updated_at: now },
229
+ pairings: firstPairing ? [firstPairing] : [],
162
230
  device: params.device,
163
231
  peer_devices: [],
164
232
  // 0 = « aucun epoch » et non « premier epoch ». Le premier epoch remis est le 1 ;
@@ -250,18 +318,25 @@ export function summarizeConnection(cwd = process.cwd()) {
250
318
  stage: 'unpaired',
251
319
  current_epoch: 0,
252
320
  readable_epochs: [],
321
+ pairings: [],
253
322
  sync,
254
323
  recovery: { ready: false, enrolled: 0, required: REQUIRED_RECOVERY_DEVICES },
255
324
  };
256
325
  }
257
326
  return {
258
- connected: state.enrollment.stage === 'active',
327
+ // Connecté dès qu'AU MOINS UN agent est actif, OU que le miroir l'indique. Le OR est
328
+ // une ceinture de sécurité pour l'affichage : un état projeté depuis v2, ou construit
329
+ // à la main, peut porter un miroir actif sans pairing correspondant. Mieux vaut
330
+ // afficher « connecté » et laisser l'opérateur voir que masquer un appairage réel.
331
+ connected: hasActivePairing(state) || state.enrollment.stage === 'active',
259
332
  cloud_project_id: state.cloud_project_id,
260
333
  stage: state.enrollment.stage,
261
334
  role: state.enrollment.role,
262
335
  current_epoch: state.keys.current_epoch,
263
336
  readable_epochs: state.keys.known_epochs,
264
337
  device_fingerprint: state.device.x25519_fingerprint,
338
+ cloud_url: state.cloud_url,
339
+ pairings: state.pairings.map((p) => ({ agent_id: p.agent_id, stage: p.stage, role: p.role })),
265
340
  sync,
266
341
  last_pull_at: state.sync.last_pull_at,
267
342
  recovery: recoveryReadiness(state),
@@ -48,6 +48,9 @@ export const MCP_HEADLESS_AUTO_TOOL_NAMES = [
48
48
  'bclaw_code_status',
49
49
  'bclaw_code_find',
50
50
  'bclaw_code_brief',
51
+ 'bclaw_code_impact',
52
+ 'bclaw_code_export',
53
+ 'bclaw_code_outline',
51
54
  'bclaw_send_message',
52
55
  'bclaw_ack_message',
53
56
  'bclaw_write_note',