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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-cloud.js +63 -0
- package/dist/cli.js +2 -3
- package/dist/commands/cloud.js +198 -0
- package/dist/commands/export.js +3 -3
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp-write-claims.js +162 -46
- package/dist/commands/mcp-write-entities.js +67 -0
- package/dist/commands/mcp.js +64 -1
- package/dist/commands/session-end.js +0 -102
- package/dist/commands/session-start.js +0 -23
- package/dist/commands/switch.js +41 -12
- package/dist/core/actions.js +25 -1
- package/dist/core/agent-files.js +19 -0
- package/dist/core/agentruns.js +68 -10
- package/dist/core/assignments.js +94 -19
- package/dist/core/claims.js +13 -24
- package/dist/core/config.js +58 -0
- package/dist/core/context-diff.js +28 -11
- package/dist/core/coordination.js +1 -3
- package/dist/core/entity-locator.js +404 -0
- package/dist/core/federation-attestation.js +96 -0
- package/dist/core/federation-canonical.js +95 -0
- package/dist/core/federation-hpke.js +213 -0
- package/dist/core/federation-inbound.js +187 -0
- package/dist/core/federation-keyring.js +241 -0
- package/dist/core/federation-message.js +5 -5
- package/dist/core/federation-outbox-v2.js +125 -0
- package/dist/core/federation-pairing.js +213 -0
- package/dist/core/federation-projection.js +336 -0
- package/dist/core/federation-relay.js +223 -0
- package/dist/core/federation-state.js +270 -0
- package/dist/core/identity.js +9 -1
- package/dist/core/ids.js +5 -0
- package/dist/core/io.js +39 -1
- package/dist/core/operations/relocate.js +40 -10
- package/dist/core/schema.js +24 -17
- package/dist/core/sequence.js +47 -6
- package/dist/core/store-resolution.js +99 -26
- package/dist/core/workspace-projects.js +23 -2
- package/dist/core/worktree.js +59 -1
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/cli.md +73 -40
- package/docs/concepts/federation-v2-rfc.md +275 -0
- package/docs/index.md +1 -0
- package/package.json +2 -2
- package/dist/cli/register-federation.js +0 -258
- package/dist/core/federation-cloud.js +0 -245
- package/dist/core/federation-outbox.js +0 -292
- 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
|
package/dist/core/identity.js
CHANGED
|
@@ -260,7 +260,15 @@ function resolveCurrentAgentName() {
|
|
|
260
260
|
return process.env.BRAINCLAW_AGENT_NAME;
|
|
261
261
|
return detectAiAgent()?.name;
|
|
262
262
|
}
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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).
|