brainclaw 1.20.4 → 1.22.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 (51) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-cloud.js +63 -0
  3. package/dist/cli.js +2 -3
  4. package/dist/commands/cloud.js +198 -0
  5. package/dist/commands/export.js +3 -3
  6. package/dist/commands/init.js +11 -0
  7. package/dist/commands/mcp-write-claims.js +162 -46
  8. package/dist/commands/mcp-write-entities.js +67 -0
  9. package/dist/commands/mcp.js +64 -1
  10. package/dist/commands/session-end.js +0 -102
  11. package/dist/commands/session-start.js +0 -23
  12. package/dist/commands/switch.js +41 -12
  13. package/dist/core/actions.js +25 -1
  14. package/dist/core/agent-files.js +19 -0
  15. package/dist/core/agentruns.js +68 -10
  16. package/dist/core/assignments.js +94 -19
  17. package/dist/core/claims.js +13 -24
  18. package/dist/core/config.js +58 -0
  19. package/dist/core/context-diff.js +28 -11
  20. package/dist/core/coordination.js +1 -3
  21. package/dist/core/entity-locator.js +404 -0
  22. package/dist/core/federation-attestation.js +96 -0
  23. package/dist/core/federation-canonical.js +95 -0
  24. package/dist/core/federation-hpke.js +213 -0
  25. package/dist/core/federation-inbound.js +187 -0
  26. package/dist/core/federation-keyring.js +241 -0
  27. package/dist/core/federation-message.js +5 -5
  28. package/dist/core/federation-outbox-v2.js +125 -0
  29. package/dist/core/federation-pairing.js +213 -0
  30. package/dist/core/federation-projection.js +336 -0
  31. package/dist/core/federation-relay.js +223 -0
  32. package/dist/core/federation-state.js +270 -0
  33. package/dist/core/identity.js +9 -1
  34. package/dist/core/ids.js +5 -0
  35. package/dist/core/io.js +39 -1
  36. package/dist/core/operations/relocate.js +40 -10
  37. package/dist/core/schema.js +24 -17
  38. package/dist/core/sequence.js +47 -6
  39. package/dist/core/store-resolution.js +99 -26
  40. package/dist/core/workspace-projects.js +23 -2
  41. package/dist/core/worktree.js +59 -1
  42. package/dist/facts.js +7 -7
  43. package/dist/facts.json +6 -6
  44. package/docs/cli.md +73 -40
  45. package/docs/concepts/federation-v2-rfc.md +275 -0
  46. package/docs/index.md +1 -0
  47. package/package.json +2 -2
  48. package/dist/cli/register-federation.js +0 -258
  49. package/dist/core/federation-cloud.js +0 -245
  50. package/dist/core/federation-outbox.js +0 -292
  51. package/dist/core/federation-signing.js +0 -115
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Commandes cloud matérialisées localement — le RELAIS (pln#651 étape 7, dec#154).
3
+ *
4
+ * ── CE QUI SURVIT INTACT AU CHIFFREMENT ───────────────────────────────────────
5
+ * À écrire d'emblée, pour qu'on ne « résolve » pas un faux problème : `base_rev` compare
6
+ * un IDENTIFIANT DE RÉVISION, pas du contenu. L'idempotence, `operation_id`, la
7
+ * provenance et l'audit ne référencent que des ids et des actions. Rien de tout cela n'a
8
+ * besoin de lire le clair, et le chiffrement ne gêne donc en rien ce module.
9
+ *
10
+ * LE RELAIS N'ÉCRIT JAMAIS DE CONTENU. Le contenu reste en écriture depuis le local
11
+ * uniquement. Une commande venue du dashboard porte sur des MÉTADONNÉES : priorité,
12
+ * ordre, rang, statut, métadonnées de roadmap. C'est la conséquence directe de dec#154 —
13
+ * « le Cloud est une projection et un relais ; le local est la source de vérité ».
14
+ *
15
+ * ── LA TROISIÈME CLASSE D'APPELANTS (dec#155) ─────────────────────────────────
16
+ * Le relais cloud n'a NI session, NI cwd, NI contexte ambiant : seulement un id d'entité
17
+ * et un `base_rev`. C'est le cas le plus pur du routage autoritatif par l'entité, déjà en
18
+ * place côté core depuis la 1.21.0.
19
+ *
20
+ * NE PAS RÉINVENTER DE RÉSOLUTION AMBIANTE POUR LUI. La tentation est réelle — « si le
21
+ * projet n'est pas précisé, prendre le projet actif » — et c'est exactement la dérive que
22
+ * pln#648/649 ont corrigée pour le routage local. Une commande sans cible résoluble est
23
+ * REFUSÉE, jamais devinée.
24
+ *
25
+ * ── LES CONFLITS ──────────────────────────────────────────────────────────────
26
+ * Présentés avec une proposition de résolution, JAMAIS de last-write-wins silencieux. Et
27
+ * c'est le contrat de refus de T3 exprimé sur un autre transport : une divergence PROUVÉE
28
+ * refuse et nomme ; une ABSENCE retombe sur la réponse ambiante. Le même mécanisme, pas
29
+ * un second.
30
+ */
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { z } from 'zod';
34
+ import { memoryDir, writeFileAtomic } from './io.js';
35
+ import { nowISO } from './ids.js';
36
+ import { canonicalJson } from './federation-canonical.js';
37
+ import { logger } from './logger.js';
38
+ const JOURNAL_DIR = ['coordination', 'federation', 'commands'];
39
+ /**
40
+ * Les seuls champs qu'une commande cloud peut toucher.
41
+ *
42
+ * LISTE FERMÉE ET NON EXTENSIBLE PAR CONFIGURATION : c'est la traduction exécutable de
43
+ * « le relais n'écrit jamais de contenu ». Ajouter `text` ou `description` ici ferait du
44
+ * Cloud une source d'écriture de contenu et retournerait dec#154.
45
+ */
46
+ export const RELAYABLE_FIELDS = ['priority', 'rank', 'status'];
47
+ export const CloudCommandSchema = z.object({
48
+ /** Identité de l'opération, créée par l'émetteur et REJOUÉE à l'identique en cas de retry. */
49
+ operation_id: z.string().min(1),
50
+ /** Cible : un id d'entité opaque. Pas de projet, pas de cwd, pas de session (dec#155). */
51
+ object_id: z.string().min(1),
52
+ /** Révision sur laquelle l'émetteur s'appuie. Un décalage produit un conflit VISIBLE. */
53
+ base_rev: z.number().int().nonnegative(),
54
+ field: z.enum(RELAYABLE_FIELDS),
55
+ value: z.union([z.string(), z.number()]),
56
+ /** Identité Ed25519 de l'émetteur — audité, pas cru sur parole. */
57
+ issued_by: z.string().min(1),
58
+ issued_at: z.string().min(1),
59
+ }).strict();
60
+ function journalDir(cwd) {
61
+ return path.join(memoryDir(cwd), ...JOURNAL_DIR);
62
+ }
63
+ /**
64
+ * Le journal est indexé par `operation_id` — c'est ce qui rend le rejeu inoffensif.
65
+ *
66
+ * Un index par (object_id, base_rev) ne suffirait pas : deux commandes distinctes peuvent
67
+ * légitimement viser la même révision d'un même objet (changer la priorité, puis le rang).
68
+ */
69
+ function entryPath(cwd, operationId) {
70
+ // Le nom de fichier est assaini : un operation_id venu du réseau ne doit pas pouvoir
71
+ // écrire hors du répertoire du journal via '../'.
72
+ const safe = operationId.replace(/[^A-Za-z0-9_.-]/g, '_');
73
+ return path.join(journalDir(cwd), `${safe}.json`);
74
+ }
75
+ export function loadJournalEntry(cwd, operationId) {
76
+ const filepath = entryPath(cwd, operationId);
77
+ if (!fs.existsSync(filepath))
78
+ return undefined;
79
+ try {
80
+ return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
81
+ }
82
+ catch (err) {
83
+ // Une entrée illisible n'est PAS traitée comme absente : la traiter ainsi ferait
84
+ // réappliquer une commande déjà appliquée, ce que tout ce module cherche à empêcher.
85
+ logger.warn(`Entrée de journal illisible (${operationId}) : ${err instanceof Error ? err.message : String(err)}`);
86
+ // `cause` conservée : sans elle, un opérateur voit « journal corrompu » sans savoir
87
+ // si c'est un JSON tronqué, un encodage cassé ou un disque plein.
88
+ throw new Error(`Journal corrompu pour l'opération ${operationId} — refus d'appliquer à l'aveugle.`, { cause: err });
89
+ }
90
+ }
91
+ function writeEntry(cwd, entry) {
92
+ const filepath = entryPath(cwd, entry.operation_id);
93
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
94
+ writeFileAtomic(filepath, `${JSON.stringify(entry, null, 2)}\n`);
95
+ }
96
+ /**
97
+ * Applique une commande cloud au journal local.
98
+ *
99
+ * `resolveLocalRev` est INJECTÉ plutôt que lu ici : ce module ne connaît pas le stockage
100
+ * des entités, et lui donner cette connaissance en ferait un second chemin d'écriture sur
101
+ * la mémoire — précisément ce que « le relais n'écrit jamais de contenu » interdit. Il
102
+ * écrit le journal ; c'est l'appelant qui, à partir du journal, applique un changement de
103
+ * métadonnée par la voie locale normale.
104
+ *
105
+ * TROIS ISSUES ET AUCUN ÉCRASEMENT SILENCIEUX :
106
+ * applied — base_rev correspond, l'effet est enregistré ;
107
+ * duplicate — operation_id déjà connu, aucun second effet (idempotence) ;
108
+ * conflict — base_rev périmé : l'entrée est écrite en état `conflict` AVEC une
109
+ * proposition, et attend une décision humaine.
110
+ */
111
+ export function applyCloudCommand(params) {
112
+ const parsed = CloudCommandSchema.safeParse(params.raw);
113
+ if (!parsed.success) {
114
+ return { status: 'refused', reason: `commande invalide : ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` };
115
+ }
116
+ const cmd = parsed.data;
117
+ // IDEMPOTENCE D'ABORD. Rejouer une commande déjà appliquée doit être un no-op, y compris
118
+ // si la révision locale a changé entre-temps — sinon un retry réseau produirait un
119
+ // conflit fantôme sur une opération pourtant déjà réussie.
120
+ const existing = loadJournalEntry(params.cwd, cmd.operation_id);
121
+ if (existing) {
122
+ return { status: 'duplicate', entry: existing };
123
+ }
124
+ const localRev = params.resolveLocalRev(cmd.object_id);
125
+ if (localRev === undefined) {
126
+ // TROISIÈME CLASSE D'APPELANTS (dec#155) : pas de repli ambiant. Un objet inconnu est
127
+ // refusé et nommé, jamais rattaché au « projet actif » par défaut.
128
+ return { status: 'refused', reason: `objet '${cmd.object_id}' inconnu localement — aucune résolution ambiante pour le relais cloud (dec#155).` };
129
+ }
130
+ const base = {
131
+ operation_id: cmd.operation_id,
132
+ object_id: cmd.object_id,
133
+ base_rev: cmd.base_rev,
134
+ field: cmd.field,
135
+ value: cmd.value,
136
+ issued_by: cmd.issued_by,
137
+ issued_at: cmd.issued_at,
138
+ materialized_at: nowISO(),
139
+ };
140
+ if (cmd.base_rev !== localRev) {
141
+ // CONFLIT VISIBLE, jamais un écrasement. La proposition est formulée ici parce que
142
+ // c'est ici qu'on connaît les deux révisions ; la rendre plus tard obligerait
143
+ // l'interface à re-déduire ce que le journal savait déjà.
144
+ const entry = {
145
+ ...base,
146
+ state: 'conflict',
147
+ conflict: {
148
+ local_rev: localRev,
149
+ proposal: localRev > cmd.base_rev
150
+ ? `Le local a avancé (rév. ${localRev} > ${cmd.base_rev}). Rejouer la commande sur la révision ${localRev}, ou l'abandonner si le changement local la rend caduque.`
151
+ : `La commande s'appuie sur une révision (${cmd.base_rev}) postérieure au local (${localRev}) — le local a probablement été restauré. Vérifier avant d'appliquer.`,
152
+ },
153
+ };
154
+ writeEntry(params.cwd, entry);
155
+ return { status: 'conflict', entry };
156
+ }
157
+ const entry = { ...base, state: 'pending' };
158
+ writeEntry(params.cwd, entry);
159
+ return { status: 'applied', entry };
160
+ }
161
+ /** Marque une entrée synchronisée une fois l'effet local réellement appliqué. */
162
+ export function markCommandSynced(cwd, operationId) {
163
+ const entry = loadJournalEntry(cwd, operationId);
164
+ if (!entry)
165
+ return false;
166
+ writeEntry(cwd, { ...entry, state: 'synced' });
167
+ return true;
168
+ }
169
+ /**
170
+ * Résout un conflit par une DÉCISION EXPLICITE.
171
+ *
172
+ * Il n'existe volontairement aucune résolution automatique : dec#154 dit « jamais de
173
+ * last-write-wins silencieux », et un mode « auto » finirait par être le défaut.
174
+ */
175
+ export function resolveCommandConflict(params) {
176
+ const entry = loadJournalEntry(params.cwd, params.operationId);
177
+ if (!entry || entry.state !== 'conflict')
178
+ return undefined;
179
+ const resolved = {
180
+ ...entry,
181
+ state: params.decision === 'accept' ? 'pending' : 'synced',
182
+ conflict: undefined,
183
+ };
184
+ writeEntry(params.cwd, resolved);
185
+ return resolved;
186
+ }
187
+ /** Entrées du journal dans un état donné — sert à rendre les conflits visibles. */
188
+ export function listCommands(cwd, state) {
189
+ const dir = journalDir(cwd);
190
+ if (!fs.existsSync(dir))
191
+ return [];
192
+ const out = [];
193
+ for (const name of fs.readdirSync(dir)) {
194
+ if (!name.endsWith('.json'))
195
+ continue;
196
+ try {
197
+ const entry = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
198
+ if (!state || entry.state === state)
199
+ out.push(entry);
200
+ }
201
+ catch {
202
+ logger.warn(`Entrée de journal ignorée (illisible) : ${name}`);
203
+ }
204
+ }
205
+ return out.sort((a, b) => a.materialized_at.localeCompare(b.materialized_at));
206
+ }
207
+ /**
208
+ * Empreinte d'audit d'une commande — ce qui a été demandé, par qui, sur quelle révision.
209
+ *
210
+ * Calculée sur les octets CANONIQUES pour qu'elle soit reproductible des deux côtés : un
211
+ * audit qu'on ne peut pas recalculer identiquement ne prouve rien.
212
+ */
213
+ export function commandAuditDigest(cmd) {
214
+ return canonicalJson({
215
+ operation_id: cmd.operation_id,
216
+ object_id: cmd.object_id,
217
+ base_rev: cmd.base_rev,
218
+ field: cmd.field,
219
+ value: cmd.value,
220
+ issued_by: cmd.issued_by,
221
+ });
222
+ }
223
+ //# sourceMappingURL=federation-relay.js.map
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Fédération v2 — état local de connexion (pln#651 étape 3).
3
+ *
4
+ * Création propre, AUCUNE migration (dec#156) : ce module ne lit ni `cloud_sync` ni
5
+ * `BRAINCLAW_CLOUD_*`. Le chemin v1 a été démoli en étape 2, et le défaut vivant qu'il
6
+ * portait — la SEULE PRÉSENCE d'une variable d'environnement valant consentement au
7
+ * sync — ne doit pas se reconstituer ici. Le consentement est un fichier écrit par une
8
+ * cérémonie d'appairage explicite (étape 4), rien d'autre.
9
+ *
10
+ * ── OÙ VIT QUOI, ET POURQUOI ──────────────────────────────────────────────────
11
+ * `.brainclaw/coordination/federation/connection.json` (workspace, ce module)
12
+ * Le lien workspace ↔ cloud_project_id, l'identité d'appareil PUBLIQUE, les
13
+ * epochs connus, la position de sync, les états visibles. AUCUN SECRET.
14
+ *
15
+ * `~/.brainclaw/keys/` (federation-keyring.ts)
16
+ * Les clés privées. Hors du store, parce qu'un store de workspace se copie,
17
+ * se synchronise et — dans d'autres projets que celui-ci — se committe.
18
+ *
19
+ * La frontière est vérifiée par un test, pas seulement par cette phrase : le pack de
20
+ * l'étape 8 injecte une sentinelle et exige qu'aucun octet de clé privée n'atteigne
21
+ * `.brainclaw/`.
22
+ *
23
+ * ── LES TROIS ÉTATS SONT UNE EXIGENCE, PAS UN CONFORT (dec#154) ───────────────
24
+ * « Le Cloud est une projection et un relais ; le local est la source de vérité. » Une
25
+ * opération venue du Cloud se matérialise dans le journal local AVEC UN ÉTAT VISIBLE :
26
+ * pending / synced / conflict. Un état invisible transformerait le relais en autorité
27
+ * silencieuse — exactement ce que dec#154 refuse.
28
+ */
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { memoryDir, writeFileAtomic } from './io.js';
32
+ import { generateId, nowISO } from './ids.js';
33
+ import { heldEpochs } from './federation-keyring.js';
34
+ import { counters as outboxCounters } from './federation-outbox-v2.js';
35
+ import { logger } from './logger.js';
36
+ const CONNECTION_FILE = 'connection.json';
37
+ export const FEDERATION_STATE_SCHEMA = 'brainclaw.federation-connection/v2';
38
+ /** Nombre d'appareils de récupération exigés avant la première enveloppe (RFC §5.3). */
39
+ export const REQUIRED_RECOVERY_DEVICES = 2;
40
+ // ── Emplacement ───────────────────────────────────────────────────────────────
41
+ export function connectionStatePath(cwd = process.cwd()) {
42
+ return path.join(memoryDir(cwd), 'coordination', 'federation', CONNECTION_FILE);
43
+ }
44
+ // ── Lecture ───────────────────────────────────────────────────────────────────
45
+ /**
46
+ * Charge l'état de connexion, ou `undefined` si le workspace n'est pas appairé.
47
+ *
48
+ * FAIL-CLOSED SUR ÉTAT ILLISIBLE : un JSON corrompu renvoie `undefined` et journalise,
49
+ * il ne renvoie PAS un état par défaut. Un défaut fabriqué ferait croire à un appairage
50
+ * avec un `current_epoch` de 0, et l'appelant tenterait de sceller sous une clé
51
+ * inexistante. « Pas appairé » est une réponse sûre ; « appairé, epoch 0 » ne l'est pas.
52
+ */
53
+ export function loadConnectionState(cwd = process.cwd()) {
54
+ const filepath = connectionStatePath(cwd);
55
+ if (!fs.existsSync(filepath))
56
+ return undefined;
57
+ let raw;
58
+ try {
59
+ raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
60
+ }
61
+ catch (err) {
62
+ logger.warn(`État de connexion fédération illisible (${filepath}): ${err instanceof Error ? err.message : String(err)}`);
63
+ return undefined;
64
+ }
65
+ 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}).`);
69
+ return undefined;
70
+ }
71
+ return normalizeState(state, cwd);
72
+ }
73
+ /**
74
+ * Réconcilie l'état déclaré avec le DISQUE.
75
+ *
76
+ * `known_epochs` dit ce que l'appareil croit détenir ; `heldEpochs()` lit ce qu'il
77
+ * détient réellement. Le désaccord n'est pas théorique : une restauration partielle de
78
+ * sauvegarde, ou un `disconnect` interrompu, produit exactement cela. Faire confiance au
79
+ * JSON conduirait à tenter un déchiffrement sous une clé absente et à rendre l'erreur au
80
+ * mauvais endroit — loin de la cause.
81
+ */
82
+ function normalizeState(state, cwd) {
83
+ const onDisk = heldEpochs(state.cloud_project_id);
84
+ const declared = state.keys?.known_epochs ?? [];
85
+ const missing = declared.filter((e) => !onDisk.includes(e));
86
+ if (missing.length > 0) {
87
+ logger.warn(`Trousseau incomplet pour ${state.cloud_project_id} : epoch(s) ${missing.join(', ')} déclaré(s) ` +
88
+ `mais absent(s) de ~/.brainclaw/keys/. Ces révisions ne sont pas déchiffrables sur cet appareil.`);
89
+ }
90
+ return {
91
+ ...state,
92
+ workspace_path: state.workspace_path ?? path.resolve(cwd),
93
+ peer_devices: state.peer_devices ?? [],
94
+ keys: {
95
+ current_epoch: state.keys?.current_epoch ?? 0,
96
+ // Le disque fait autorité sur ce qui est LISIBLE.
97
+ known_epochs: onDisk,
98
+ },
99
+ sync: {
100
+ feed_cursor: state.sync?.feed_cursor,
101
+ high_water: state.sync?.high_water ?? {},
102
+ last_pull_at: state.sync?.last_pull_at,
103
+ last_push_at: state.sync?.last_push_at,
104
+ },
105
+ counters: {
106
+ pending: state.counters?.pending ?? 0,
107
+ synced: state.counters?.synced ?? 0,
108
+ conflict: state.counters?.conflict ?? 0,
109
+ },
110
+ };
111
+ }
112
+ // ── Écriture ──────────────────────────────────────────────────────────────────
113
+ /**
114
+ * Persiste l'état de connexion de façon atomique.
115
+ *
116
+ * REFUSE D'ÉCRIRE UN SECRET : le contrôle ci-dessous n'est pas de la paranoïa décorative.
117
+ * `{...state, private_key}` compile, et une clé privée sérialisée dans un fichier du
118
+ * store serait ensuite copiée par tout ce qui copie un store. C'est le même raisonnement
119
+ * que les trois filets de l'étape 5 : le typage TypeScript est une BORNE INFÉRIEURE, pas
120
+ * une garantie d'exécution — chaque clé présente à l'exécution est sérialisée.
121
+ */
122
+ export function saveConnectionState(state, cwd = process.cwd()) {
123
+ assertNoSecret(state);
124
+ const next = { ...state, updated_at: nowISO() };
125
+ const filepath = connectionStatePath(cwd);
126
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
127
+ writeFileAtomic(filepath, `${JSON.stringify(next, null, 2)}\n`);
128
+ }
129
+ const SECRET_MARKERS = ['PRIVATE KEY', 'BEGIN OPENSSH', 'BEGIN RSA', 'BEGIN EC PARAMETERS'];
130
+ /**
131
+ * Refuse tout état contenant du matériel de clé privée, quel que soit le NOM du champ.
132
+ *
133
+ * Le contrôle porte sur le CONTENU sérialisé et non sur une liste de champs interdits :
134
+ * une liste de noms ne rattrape pas un champ ajouté demain, alors qu'un PEM privé porte
135
+ * toujours son en-tête. C'est le même choix que le filet 2 de l'étape 5 — fail-closed sur
136
+ * ce qui sort, pas allowlist sur ce qu'on a pensé à interdire.
137
+ */
138
+ function assertNoSecret(state) {
139
+ const serialized = JSON.stringify(state);
140
+ for (const marker of SECRET_MARKERS) {
141
+ if (serialized.includes(marker)) {
142
+ throw new Error(`Refus d'écrire l'état de connexion : du matériel de clé privée ('${marker}') s'y trouve. ` +
143
+ `Les secrets vont dans ~/.brainclaw/keys/ via federation-keyring.ts, jamais dans le store de workspace.`);
144
+ }
145
+ }
146
+ }
147
+ /**
148
+ * Crée l'état de connexion initial d'un workspace fraîchement appairé.
149
+ *
150
+ * `stage: 'pending'` et non `'active'` : créer l'état ne vaut pas approbation. Le passage
151
+ * à `active` appartient à la cérémonie de l'étape 4, après preuve de possession ET
152
+ * approbation humaine. Un état créé optimiste rejouerait le défaut de la v1 — un artefact
153
+ * local suffisant à déclencher du sync.
154
+ */
155
+ export function createConnectionState(params) {
156
+ const now = nowISO();
157
+ return {
158
+ schema: FEDERATION_STATE_SCHEMA,
159
+ cloud_project_id: params.cloudProjectId,
160
+ workspace_path: path.resolve(params.workspacePath ?? process.cwd()),
161
+ enrollment: { stage: 'pending', enrollment_id: params.enrollmentId, updated_at: now },
162
+ device: params.device,
163
+ peer_devices: [],
164
+ // 0 = « aucun epoch » et non « premier epoch ». Le premier epoch remis est le 1 ;
165
+ // sceller sous l'epoch 0 doit être impossible, pas silencieusement plausible.
166
+ keys: { current_epoch: 0, known_epochs: [] },
167
+ sync: { high_water: {} },
168
+ counters: { pending: 0, synced: 0, conflict: 0 },
169
+ created_at: now,
170
+ updated_at: now,
171
+ };
172
+ }
173
+ /**
174
+ * Identifiant d'appareil, indépendant de l'identité d'agent (RFC §5.1).
175
+ *
176
+ * Un agent peut tourner sur plusieurs machines et une machine porter plusieurs agents ;
177
+ * c'est l'APPAREIL qui détient une clé de déchiffrement et qu'on révoque en cas de perte.
178
+ * Réutiliser l'agent_id ferait qu'une révocation coupe l'agent partout à la fois.
179
+ */
180
+ export function newDeviceId() {
181
+ return generateId('federation_devices');
182
+ }
183
+ // ── Anti-rejeu ────────────────────────────────────────────────────────────────
184
+ /**
185
+ * Décide si une révision entrante est acceptable pour un objet donné.
186
+ *
187
+ * STRICTEMENT SUPÉRIEURE (RFC §6.5) : l'égalité est refusée ici, et le dédoublonnage
188
+ * d'une enveloppe déjà connue se fait par `idempotency_key` à l'étape 6 — deux
189
+ * mécanismes distincts pour deux questions distinctes. Confondre les deux ferait
190
+ * accepter un rejeu de même révision porteur d'un contenu différent.
191
+ */
192
+ export function acceptsRevision(state, objectId, incomingRev) {
193
+ const seen = state.sync.high_water[objectId];
194
+ return seen === undefined || incomingRev > seen;
195
+ }
196
+ /** Avance la barrière anti-rejeu. Ne régresse JAMAIS, même si l'appelant le demande. */
197
+ export function recordRevision(state, objectId, rev) {
198
+ const seen = state.sync.high_water[objectId];
199
+ if (seen !== undefined && rev <= seen)
200
+ return state;
201
+ return { ...state, sync: { ...state.sync, high_water: { ...state.sync.high_water, [objectId]: rev } } };
202
+ }
203
+ /**
204
+ * Un projet ne peut émettre sa PREMIÈRE enveloppe v2 qu'après l'enrôlement de deux
205
+ * appareils de récupération indépendamment attestés (RFC §5.3).
206
+ *
207
+ * POURQUOI CETTE PORTE EXISTE ICI, dans l'état et non dans la commande : un enrôlement
208
+ * par appareil sans scénario de perte produit des workspaces DÉFINITIVEMENT illisibles.
209
+ * Si tous les porteurs sont perdus, le passé scellé est irrécupérable par construction —
210
+ * aucun reset côté Cloud ne le restaure. La porte est donc au plus près de la donnée qui
211
+ * la conditionne, pour qu'un second appelant ne puisse pas l'oublier.
212
+ *
213
+ * Les appareils révoqués ne comptent pas : deux porteurs dont un révoqué n'offrent aucun
214
+ * chemin de remplacement.
215
+ */
216
+ export function recoveryReadiness(state) {
217
+ const all = [state.device, ...state.peer_devices];
218
+ const enrolled = all.filter((d) => d.recovery && !d.revoked_at).length;
219
+ if (enrolled >= REQUIRED_RECOVERY_DEVICES) {
220
+ return { ready: true, enrolled, required: REQUIRED_RECOVERY_DEVICES };
221
+ }
222
+ return {
223
+ ready: false,
224
+ enrolled,
225
+ required: REQUIRED_RECOVERY_DEVICES,
226
+ reason: `${enrolled}/${REQUIRED_RECOVERY_DEVICES} appareil(s) de récupération attesté(s). ` +
227
+ `Sans un second porteur, la perte de cet appareil rendrait le passé scellé irrécupérable — ` +
228
+ `aucune restauration côté cloud ne le ramènerait.`,
229
+ };
230
+ }
231
+ /**
232
+ * Ce que `brainclaw cloud status` rend — le critère de sortie « les trois états de sync
233
+ * sont observables par une commande ».
234
+ *
235
+ * DEUX SOURCES, ET LES DEUX SONT LE DISQUE, PAS LA DÉCLARATION :
236
+ * `readable_epochs` vient de `heldEpochs()` via la réconciliation du chargement ;
237
+ * `sync` vient du comptage réel de l'outbox, pas des compteurs de `connection.json`.
238
+ *
239
+ * Les compteurs persistés restent un cache d'affichage bon marché pour les appelants qui
240
+ * n'ont pas besoin d'exactitude. Un STATUT, lui, est consulté précisément quand on doute :
241
+ * s'il relisait un compteur que le code a lui-même incrémenté, il n'observerait rien et
242
+ * rassurerait à tort au pire moment.
243
+ */
244
+ export function summarizeConnection(cwd = process.cwd()) {
245
+ const state = loadConnectionState(cwd);
246
+ const sync = outboxCounters(cwd);
247
+ if (!state) {
248
+ return {
249
+ connected: false,
250
+ stage: 'unpaired',
251
+ current_epoch: 0,
252
+ readable_epochs: [],
253
+ sync,
254
+ recovery: { ready: false, enrolled: 0, required: REQUIRED_RECOVERY_DEVICES },
255
+ };
256
+ }
257
+ return {
258
+ connected: state.enrollment.stage === 'active',
259
+ cloud_project_id: state.cloud_project_id,
260
+ stage: state.enrollment.stage,
261
+ role: state.enrollment.role,
262
+ current_epoch: state.keys.current_epoch,
263
+ readable_epochs: state.keys.known_epochs,
264
+ device_fingerprint: state.device.x25519_fingerprint,
265
+ sync,
266
+ last_pull_at: state.sync.last_pull_at,
267
+ recovery: recoveryReadiness(state),
268
+ };
269
+ }
270
+ //# sourceMappingURL=federation-state.js.map
@@ -260,7 +260,15 @@ function resolveCurrentAgentName() {
260
260
  return process.env.BRAINCLAW_AGENT_NAME;
261
261
  return detectAiAgent()?.name;
262
262
  }
263
- function resolveExplicitSessionId(env = process.env) {
263
+ /**
264
+ * The session id the CALLER named, via argument or env. Exported (pln#648 review
265
+ * P1) because store-resolution must tell a STRONGLY identified session (exact id,
266
+ * or a record whose pid is this process) from a WEAKLY adopted one (the pidless
267
+ * candidate at line ~145, or the legacy `.current-session` fallback, which is
268
+ * returned with no agent/user/pid/TTL check at all). Only the former may steer
269
+ * resolution from a store the agent never named.
270
+ */
271
+ export function resolveExplicitSessionId(env = process.env) {
264
272
  return env.BRAINCLAW_SESSION_ID?.trim()
265
273
  || env.OPENCLAW_SESSION_ID?.trim()
266
274
  || env.CLAUDE_SESSION_ID?.trim()
package/dist/core/ids.js CHANGED
@@ -24,6 +24,11 @@ const PREFIXES = {
24
24
  // prefix-based routing (dispatch_status). Canonical prefix is 'rtn'.
25
25
  runtime_note: 'rtn',
26
26
  runtime_notes: 'rtn',
27
+ // pln#651 étape 3 — identité d'APPAREIL de la fédération v2, distincte de l'identité
28
+ // d'agent. Entrée EXPLICITE et non laissée au fallback slice(0,3) : c'est ce fallback
29
+ // qui a produit la collision de can_b8d53d18 ('runtime_note' → 'run', déjà pris par
30
+ // agent_run). Il rend 'dev' ici par hasard, pas par contrat.
31
+ federation_devices: 'dev',
27
32
  };
28
33
  const ID_COUNTER_FILE = '.id-counter.json';
29
34
  function counterPath(cwd, preferredDirName = '.brainclaw') {
package/dist/core/io.js CHANGED
@@ -13,7 +13,15 @@ const TMP_ORPHAN_MIN_AGE_MS = 60_000;
13
13
  * Maps legacy flat directory names to their entity-partitioned paths.
14
14
  * Used by resolveEntityDir() for backward-compatible reads and forward writes.
15
15
  */
16
- const ENTITY_DIR_MAP = {
16
+ /**
17
+ * Exported (pln#649 step 2, review P1-1) so a caller that needs the CANONICAL
18
+ * relative path for a kind can build a file path directly. `resolveEntityDir`
19
+ * answers "where do records of this kind generally live" by picking whichever
20
+ * directory has content — which is the wrong primitive when the question is
21
+ * "where is THIS record", because a mid-migration store makes the other layout
22
+ * invisible. Read-only by contract: never mutate this map.
23
+ */
24
+ export const ENTITY_DIR_MAP = {
17
25
  // memory/ — Project entity: durable knowledge
18
26
  'constraints': 'memory/constraints',
19
27
  'decisions': 'memory/decisions',
@@ -83,6 +91,36 @@ export function resolveEntityDir(subdir, cwd = process.cwd(), mode = 'read', pre
83
91
  // Neither exists — return entity path (caller will handle missing dir)
84
92
  return entityPath;
85
93
  }
94
+ /**
95
+ * EVERY directory a record of `subdir` can occupy in ONE store, canonical first.
96
+ *
97
+ * THE PRIMITIVE THAT WAS MISSING (pln#649, after three reviews found the same defect
98
+ * at three different call sites). `resolveEntityDir(mode='read')` answers a
99
+ * DIRECTORY question — "where do records of this kind generally live" — using a
100
+ * `hasContent` heuristic. Every by-id loader used it for a FILE question — "where is
101
+ * THIS record" — and the two are not the same: in a store mid-migration, one file in
102
+ * the canonical directory makes every legacy record invisible. That produced a
103
+ * reproduced defect in the entity locator, then again in `loadAssignment`, and it is
104
+ * still latent wherever a loader resolves a directory before looking for an id.
105
+ *
106
+ * Callers that need a specific record MUST iterate these, not pick one. Writes keep
107
+ * using `resolveEntityDir(..., 'write')`, which is always canonical, so nothing new
108
+ * is ever created in the legacy layout — this is a read-compatibility primitive, not
109
+ * a migration.
110
+ */
111
+ export function entityRecordDirs(subdir, cwd = process.cwd(), preferredDirName) {
112
+ const base = memoryDir(cwd, preferredDirName);
113
+ const mapped = ENTITY_DIR_MAP[subdir];
114
+ const legacy = path.join(base, subdir);
115
+ if (!mapped)
116
+ return [legacy];
117
+ const canonical = path.join(base, mapped);
118
+ return canonical === legacy ? [canonical] : [canonical, legacy];
119
+ }
120
+ /** The same, as record file paths for one id. */
121
+ export function entityRecordPaths(subdir, id, cwd, preferredDirName) {
122
+ return entityRecordDirs(subdir, cwd ?? process.cwd(), preferredDirName).map((d) => path.join(d, `${id}.json`));
123
+ }
86
124
  export function memoryDir(cwd = process.cwd(), preferredDirName) {
87
125
  return path.join(cwd, preferredDirName ?? MEMORY_DIR);
88
126
  }
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
- import { resolveEntityDir, writeFileAtomic } from '../io.js';
19
+ import { entityRecordPaths, resolveEntityDir, writeFileAtomic } from '../io.js';
20
20
  import { getEntitySpec } from '../entity-registry.js';
21
21
  import { appendAuditEntry } from '../audit.js';
22
22
  import { resolveProjectCwd } from '../cross-project.js';
@@ -62,16 +62,25 @@ export function relocateEntity(input) {
62
62
  if (fromCwd === toCwd) {
63
63
  throw new Error(`Source and target are the same project (${toCwd}). Nothing to move.`);
64
64
  }
65
- // Locate the source file across the entity's candidate subdirs.
65
+ // Locate the source file across the entity's candidate subdirs AND both layouts.
66
+ //
67
+ // `resolveEntityDir(sd, cwd, 'read')` picks the canonical directory as soon as it
68
+ // holds ANY file, so a record still in the pre-migration flat layout was reported
69
+ // "not found in source project" while sitting right there (pln#649 — same
70
+ // directory-vs-file confusion fixed in the locator and the by-id loaders; found
71
+ // here by a Fable audit).
66
72
  let srcFile;
67
73
  let foundSubdir;
68
74
  for (const sd of subdirs) {
69
- const candidate = path.join(resolveEntityDir(sd, fromCwd, 'read'), `${input.id}.json`);
70
- if (fs.existsSync(candidate)) {
71
- srcFile = candidate;
72
- foundSubdir = sd;
73
- break;
75
+ for (const candidate of entityRecordPaths(sd, input.id, fromCwd)) {
76
+ if (fs.existsSync(candidate)) {
77
+ srcFile = candidate;
78
+ foundSubdir = sd;
79
+ break;
80
+ }
74
81
  }
82
+ if (srcFile)
83
+ break;
75
84
  }
76
85
  if (!srcFile || !foundSubdir) {
77
86
  throw new Error(`${input.entity} '${input.id}' not found in source project (${fromCwd}).`);
@@ -85,11 +94,32 @@ export function relocateEntity(input) {
85
94
  throw new Error(`${input.entity} '${input.id}' is unreadable JSON: ${err.message}`, { cause: err });
86
95
  }
87
96
  getEntitySpec(input.entity).schema.parse(raw);
88
- // Collision guard — never overwrite an item already in the target.
97
+ // Collision guard — never overwrite an item already in the target, and never
98
+ // CREATE a second copy of the same id inside it.
99
+ //
100
+ // Checking only the canonical directory (`'write'`) was worse than a missed
101
+ // overwrite: if the target held the same id in the LEGACY layout, the guard passed
102
+ // and the move wrote a canonical copy beside it — manufacturing an intra-store
103
+ // duplicate id. Found by a Fable audit.
104
+ //
105
+ // THE MECHANISM RECORDED HERE BEFORE WAS WRONG, and is corrected rather than deleted
106
+ // because a wrong mechanism in a comment misleads the next reader more efficiently than
107
+ // no comment at all. It claimed the duplicate is "precisely the state the entity locator
108
+ // refuses as `ambiguous`, so a successful move could leave an entity permanently
109
+ // unroutable". It is not: `recordExists` is a per-STORE boolean and matches are collected
110
+ // per store, so a record duplicated across two LAYOUTS INSIDE ONE STORE collapses to a
111
+ // single `found`. Ambiguity needs two distinct STORES.
112
+ //
113
+ // The real harm is quieter and still worth the guard: the two copies drift, the loader
114
+ // reads whichever layout wins, and a delete that touches only the canonical one promotes
115
+ // the stale copy back to being the record (the zombie now fixed in assignments.ts).
89
116
  const dstDir = resolveEntityDir(foundSubdir, toCwd, 'write');
90
117
  const dstFile = path.join(dstDir, `${input.id}.json`);
91
- if (fs.existsSync(dstFile)) {
92
- throw new Error(`${input.entity} '${input.id}' already exists in the target project — refusing to overwrite.`);
118
+ for (const existing of entityRecordPaths(foundSubdir, input.id, toCwd)) {
119
+ if (fs.existsSync(existing)) {
120
+ throw new Error(`${input.entity} '${input.id}' already exists in the target project (${existing}) — refusing to overwrite `
121
+ + 'or to create a second copy of the same id.');
122
+ }
93
123
  }
94
124
  // Reference guards (plans): refuse to move work under a live claim; warn on
95
125
  // sequences that still point at it (v1 does not rewrite refs).