brainclaw 1.22.0 → 1.24.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-capture.js +15 -0
- package/dist/cli/register-cloud.js +121 -13
- package/dist/commands/cloud.js +534 -39
- package/dist/commands/loops-handlers.js +0 -1
- package/dist/commands/mcp-catalog.js +24 -256
- package/dist/commands/mcp-read-handlers.js +5 -1
- package/dist/commands/mcp-schemas.generated.js +811 -1
- package/dist/commands/mcp-write-coordination.js +16 -7
- package/dist/commands/mcp.js +45 -1
- package/dist/commands/memory-confirm.js +83 -0
- package/dist/commands/switch.js +24 -2
- package/dist/core/assignment-request-schema.js +112 -0
- package/dist/core/capture-schema.js +62 -0
- package/dist/core/claim-request-schema.js +72 -0
- package/dist/core/code-map/aggregate.js +36 -1
- package/dist/core/federation-emit.js +283 -0
- package/dist/core/federation-grant-transport.js +196 -0
- package/dist/core/federation-grant.js +223 -0
- package/dist/core/federation-keyring.js +39 -0
- package/dist/core/federation-opaque-ids.js +111 -0
- package/dist/core/federation-outbox-v2.js +36 -2
- package/dist/core/federation-pairing.js +87 -12
- package/dist/core/federation-pull.js +375 -0
- package/dist/core/federation-push.js +274 -0
- package/dist/core/federation-rotation.js +124 -0
- package/dist/core/federation-state.js +81 -6
- package/dist/core/sequence-request-schema.js +93 -0
- package/dist/core/session-request-schema.js +90 -0
- package/dist/core/step-request-schema.js +112 -0
- package/dist/core/store-resolution.js +34 -5
- package/dist/core/warnings.js +37 -0
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/design/federation-onboarding-usecases.md +254 -0
- package/docs/design/pairing-v3-brief.md +80 -0
- package/docs/integrations/mcp.md +1 -1
- package/package.json +1 -1
|
@@ -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/
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schémas zod des entrées de la famille SÉQUENCE — `bclaw_create_sequence` et
|
|
3
|
+
* `bclaw_update_sequence` (pln#599 batch 2, première famille composite).
|
|
4
|
+
*
|
|
5
|
+
* ── CE QUI CHANGE PAR RAPPORT À LA FAMILLE CAPTURE ────────────────────────────
|
|
6
|
+
* C'est la première famille COMPOSITE : les deux outils partagent un objet imbriqué,
|
|
7
|
+
* l'item de séquence, dont le schéma était jusqu'ici une constante JSON manuelle
|
|
8
|
+
* (`SEQUENCE_ITEM_INPUT_SCHEMA`) réutilisée à deux endroits.
|
|
9
|
+
*
|
|
10
|
+
* La duplication d'un sous-schéma est exactement ce qui a produit trp#180 — les tableaux
|
|
11
|
+
* de `bclaw_loop` sans `items` — parce qu'un des deux exemplaires avait été corrigé et pas
|
|
12
|
+
* l'autre. Le dériver d'une source zod unique supprime la classe entière.
|
|
13
|
+
*
|
|
14
|
+
* ── CONTRAINTE INCHANGÉE : FINGERPRINT IDENTIQUE ──────────────────────────────
|
|
15
|
+
* Le JSON Schema produit doit être byte-identique à celui écrit à la main. Les
|
|
16
|
+
* `minLength: 1` et `minimum: 1` du schéma d'item sont donc reproduits tels quels, y
|
|
17
|
+
* compris là où ils paraissent redondants : ce sont des contraintes qu'un client peut déjà
|
|
18
|
+
* avoir apprises, et les retirer serait un changement de contrat déguisé en migration.
|
|
19
|
+
*
|
|
20
|
+
* `status` reste une chaîne libre et non un enum, pour la même raison. Le resserrer est un
|
|
21
|
+
* changement de surface qui mérite sa propre décision, pas un effet de bord.
|
|
22
|
+
*/
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
/**
|
|
25
|
+
* Item de lane. Source UNIQUE — les deux outils de la famille la partagent, là où le
|
|
26
|
+
* schéma manuel existait en un exemplaire réutilisé par référence mais impossible à
|
|
27
|
+
* valider contre le code qui le consomme.
|
|
28
|
+
*/
|
|
29
|
+
export const SequenceItemInputSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
planId: z.string().min(1).describe('Plan item ID referenced by this sequence item.'),
|
|
32
|
+
stepId: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.describe('Optional plan step ID inside planId for step-level dispatch/readiness.')
|
|
36
|
+
.optional(),
|
|
37
|
+
// REQUIS, comme dans le schema d'origine. Le rendre optionnel etait un
|
|
38
|
+
// ASSOUPLISSEMENT du contrat — un appel sans rank aurait ete accepte par le schema
|
|
39
|
+
// publie puis rejete plus loin. La garde de gouvernance l'a detecte via le
|
|
40
|
+
// fingerprint.
|
|
41
|
+
rank: z
|
|
42
|
+
.number()
|
|
43
|
+
.min(1)
|
|
44
|
+
.describe('Positive integer ordering key. Ranks must be unique within a sequence.'),
|
|
45
|
+
hard_after: z
|
|
46
|
+
.array(z.string())
|
|
47
|
+
.describe('Sequence item planId values that must complete before this item becomes ready.')
|
|
48
|
+
.optional(),
|
|
49
|
+
soft_after: z
|
|
50
|
+
.array(z.string())
|
|
51
|
+
.describe('Advisory predecessor planId values; they inform ordering but do not block readiness.')
|
|
52
|
+
.optional(),
|
|
53
|
+
lane: z
|
|
54
|
+
.string()
|
|
55
|
+
.describe('Optional lane label used for parallel dispatch grouping and filtering.')
|
|
56
|
+
.optional(),
|
|
57
|
+
scope_hint: z
|
|
58
|
+
.string()
|
|
59
|
+
.describe('Optional file/path scope hint for claim and brief generation.')
|
|
60
|
+
.optional(),
|
|
61
|
+
rationale: z
|
|
62
|
+
.string()
|
|
63
|
+
.describe('Optional explanation for this item or dependency placement.')
|
|
64
|
+
.optional(),
|
|
65
|
+
})
|
|
66
|
+
.describe('Sequence lane item. planId is required; stepId optionally narrows dispatch/readiness to a specific plan step.');
|
|
67
|
+
/** Identité de l'appelant — commune aux deux outils, comme dans la famille capture. */
|
|
68
|
+
const CallerIdentity = {
|
|
69
|
+
agent: z.string().describe('Agent name.').optional(),
|
|
70
|
+
agentId: z.string().describe('Registered agent id.').optional(),
|
|
71
|
+
};
|
|
72
|
+
export const CreateSequenceRequestSchema = z.object({
|
|
73
|
+
name: z.string().describe('Sequence name.'),
|
|
74
|
+
description: z.string().describe('Optional sequence description.').optional(),
|
|
75
|
+
// Chaîne libre, PAS un enum : c'est l'état publié. Le resserrer mérite sa propre
|
|
76
|
+
// décision, pas un effet de bord de migration.
|
|
77
|
+
status: z.string().describe('Status: draft, active, archived.').optional(),
|
|
78
|
+
owner: z.string().describe('Optional sequence owner.').optional(),
|
|
79
|
+
items: z.array(SequenceItemInputSchema).describe('Sequence items in rank order.').optional(),
|
|
80
|
+
tags: z.array(z.string()).describe('Optional tags.').optional(),
|
|
81
|
+
...CallerIdentity,
|
|
82
|
+
});
|
|
83
|
+
export const UpdateSequenceRequestSchema = z.object({
|
|
84
|
+
id: z.string().describe('Sequence ID or short label.'),
|
|
85
|
+
name: z.string().describe('Optional new sequence name.').optional(),
|
|
86
|
+
description: z.string().describe('Optional new description.').optional(),
|
|
87
|
+
status: z.string().describe('Status: draft, active, archived.').optional(),
|
|
88
|
+
owner: z.string().describe('Optional sequence owner.').optional(),
|
|
89
|
+
items: z.array(SequenceItemInputSchema).describe('Optional replacement items array.').optional(),
|
|
90
|
+
tags: z.array(z.string()).describe('Optional replacement tags.').optional(),
|
|
91
|
+
...CallerIdentity,
|
|
92
|
+
});
|
|
93
|
+
//# sourceMappingURL=sequence-request-schema.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schémas zod des entrées de la famille SESSION — `bclaw_session_start` et
|
|
3
|
+
* `bclaw_session_end` (pln#599 batch 2, troisième famille composite).
|
|
4
|
+
*
|
|
5
|
+
* ── LA PARTICULARITÉ DE CETTE FAMILLE : AUCUN CHAMP REQUIS ────────────────────
|
|
6
|
+
* Les deux outils ont un `properties` fourni mais PAS de clé `required`. C'est délibéré et
|
|
7
|
+
* doit être préservé au bit près : `bclaw_session_start` sans argument est l'appel normal,
|
|
8
|
+
* et l'identité comme le contexte se résolvent depuis l'ambiance.
|
|
9
|
+
*
|
|
10
|
+
* zod n'émet `required` que s'il existe au moins un champ non-optionnel — donc marquer
|
|
11
|
+
* TOUS les champs `.optional()` reproduit exactement l'absence de la clé. C'est le
|
|
12
|
+
* pendant du piège inverse rencontré sur la famille séquence : là-bas un requis était
|
|
13
|
+
* devenu optionnel (assouplissement) ; ici, oublier un `.optional()` créerait un requis
|
|
14
|
+
* là où il n'y en avait aucun — un DURCISSEMENT qui casserait l'appel sans argument.
|
|
15
|
+
*
|
|
16
|
+
* ── CE QUI N'EST PAS RESSERRÉ, DÉLIBÉRÉMENT ───────────────────────────────────
|
|
17
|
+
* `contextProfile` et `contextFormat` énumèrent leurs valeurs dans leur description mais
|
|
18
|
+
* restent des chaînes libres : les profils sont extensibles côté produit, et un enum
|
|
19
|
+
* publié figerait cette extensibilité. `maintenanceMode` garde en revanche son enum,
|
|
20
|
+
* parce qu'il en avait déjà un.
|
|
21
|
+
*
|
|
22
|
+
* ── GARDE-FOU DE GÉNÉRATION ───────────────────────────────────────────────────
|
|
23
|
+
* zod émet `additionalProperties: false` d'office ; le générateur le retire À LA RACINE
|
|
24
|
+
* uniquement (cf. OPEN_SCHEMAS dans scripts/build-mcp-schemas.mjs). Le laisser durcirait
|
|
25
|
+
* la surface ; le retirer plus profond l'assouplirait.
|
|
26
|
+
*/
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
/** Identité de l'appelant — commune à toutes les familles migrées. */
|
|
29
|
+
const CallerIdentity = {
|
|
30
|
+
agent: z.string().describe('Agent name.').optional(),
|
|
31
|
+
agentId: z.string().describe('Registered agent id.').optional(),
|
|
32
|
+
};
|
|
33
|
+
export const SessionStartRequestSchema = z.object({
|
|
34
|
+
...CallerIdentity,
|
|
35
|
+
context: z.string().describe('Context target path.').optional(),
|
|
36
|
+
// Enum CONSERVÉ : il existait déjà dans le schéma manuel.
|
|
37
|
+
maintenanceMode: z
|
|
38
|
+
.enum(['fast', 'full'])
|
|
39
|
+
.describe('Maintenance mode. Default is full for explicit session-start calls; use fast to skip non-critical maintenance work.')
|
|
40
|
+
.optional(),
|
|
41
|
+
includeContext: z
|
|
42
|
+
.boolean()
|
|
43
|
+
.describe('Include project memory context in the response (equivalent to bclaw_get_context).')
|
|
44
|
+
.optional(),
|
|
45
|
+
includeBoard: z
|
|
46
|
+
.boolean()
|
|
47
|
+
.describe('Include agent board (plans, claims, handoffs) in the response (equivalent to bclaw_get_agent_board).')
|
|
48
|
+
.optional(),
|
|
49
|
+
// Chaîne libre : les profils sont extensibles, un enum publié figerait cette
|
|
50
|
+
// extensibilité et rejetterait un profil ajouté côté produit.
|
|
51
|
+
contextProfile: z
|
|
52
|
+
.string()
|
|
53
|
+
.describe('Context profile when includeContext is true: dev (default), dense, compact, copilot, quick, briefing, openclaw, ops, research. If unset, uses the agent default profile.')
|
|
54
|
+
.optional(),
|
|
55
|
+
contextFormat: z
|
|
56
|
+
.string()
|
|
57
|
+
.describe('Context format when includeContext is true: markdown, json, or template.')
|
|
58
|
+
.optional(),
|
|
59
|
+
});
|
|
60
|
+
export const SessionEndRequestSchema = z.object({
|
|
61
|
+
session: z.string().describe('Session ID.').optional(),
|
|
62
|
+
...CallerIdentity,
|
|
63
|
+
summary: z.string().describe('Session summary text.').optional(),
|
|
64
|
+
narrative: z
|
|
65
|
+
.string()
|
|
66
|
+
.describe('Free-text narrative of what happened in the session and why. Goes beyond the auto-generated commit list: "Tried X, failed because Y, pivoted to Z. Watch out for A."')
|
|
67
|
+
.optional(),
|
|
68
|
+
autoReflect: z.boolean().describe('Auto-reflect session notes as candidates.').optional(),
|
|
69
|
+
autoRelease: z
|
|
70
|
+
.boolean()
|
|
71
|
+
.describe('Auto-release any active claims at session end.')
|
|
72
|
+
.optional(),
|
|
73
|
+
reflectHandoff: z
|
|
74
|
+
.boolean()
|
|
75
|
+
.describe('Materialize an open handoff from git commits since session start.')
|
|
76
|
+
.optional(),
|
|
77
|
+
dispatchReview: z
|
|
78
|
+
.boolean()
|
|
79
|
+
.describe('When used with reflectHandoff, auto-dispatch a code review if the reflected handoff is reviewable.')
|
|
80
|
+
.optional(),
|
|
81
|
+
reviewer: z
|
|
82
|
+
.string()
|
|
83
|
+
.describe('Explicit reviewer for the reflected handoff review dispatch.')
|
|
84
|
+
.optional(),
|
|
85
|
+
reflect: z
|
|
86
|
+
.boolean()
|
|
87
|
+
.describe('Emit the dogfooding reflection prompt (project + your surfaces/skills/tools). Default true — pass false to suppress on a trivial session. Capture actionable findings via bclaw_quick_capture.')
|
|
88
|
+
.optional(),
|
|
89
|
+
});
|
|
90
|
+
//# sourceMappingURL=session-request-schema.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schémas zod des entrées de la famille STEP — `bclaw_add_step`, `bclaw_update_step`,
|
|
3
|
+
* `bclaw_complete_step`, `bclaw_delete_step` (pln#599 batch 2, quatrième famille).
|
|
4
|
+
*
|
|
5
|
+
* ── CE QUE CETTE FAMILLE APPREND, ET QUI CORRIGE UNE RÈGLE TROP VITE GÉNÉRALISÉE ─
|
|
6
|
+
* Sur la famille séquence j'avais formulé la consigne « retirer `additionalProperties`
|
|
7
|
+
* À LA RACINE UNIQUEMENT », au motif que le sous-schéma d'item de lane le portait déjà
|
|
8
|
+
* dans sa version manuelle. C'était vrai LÀ, et faux comme règle générale.
|
|
9
|
+
*
|
|
10
|
+
* Ici, le sous-objet `data` de `bclaw_add_step` n'a PAS d'`additionalProperties` dans la
|
|
11
|
+
* version écrite à la main. Appliquer « racine uniquement » y laisserait donc le
|
|
12
|
+
* `additionalProperties: false` émis par zod — exactement le DURCISSEMENT que la règle
|
|
13
|
+
* était censée empêcher, réintroduit par la règle elle-même.
|
|
14
|
+
*
|
|
15
|
+
* La consigne réelle n'a jamais été « racine » : c'est « reproduire le schéma manuel au
|
|
16
|
+
* bit près ». Le générateur porte désormais une profondeur de retrait PAR SCHÉMA
|
|
17
|
+
* (cf. OPEN_SCHEMAS dans scripts/build-mcp-schemas.mjs) au lieu d'une règle globale.
|
|
18
|
+
*
|
|
19
|
+
* ── DEUX FORMES D'APPEL COEXISTENT, DÉLIBÉRÉMENT ──────────────────────────────
|
|
20
|
+
* `bclaw_add_step` accepte la forme canonique `{ planId, data: {...} }` ET la forme
|
|
21
|
+
* historique `{ planId, text, assignee }`. Les deux sont publiées ; supprimer la seconde
|
|
22
|
+
* du schéma casserait les appelants existants. `title` reste un alias de `text`.
|
|
23
|
+
*
|
|
24
|
+
* ── CE QUI N'EST PAS RESSERRÉ ─────────────────────────────────────────────────
|
|
25
|
+
* `status` reste une chaîne libre bien que ses cinq valeurs soient énumérées dans sa
|
|
26
|
+
* description : en faire un enum serait un rejet nouveau sur des appels aujourd'hui
|
|
27
|
+
* acceptés, donc une décision à part entière.
|
|
28
|
+
*/
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
/** Identité de l'appelant — commune à toutes les familles migrées. */
|
|
31
|
+
const CallerIdentity = {
|
|
32
|
+
agent: z.string().describe('Agent name.').optional(),
|
|
33
|
+
agentId: z.string().describe('Registered agent id.').optional(),
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Charge utile canonique d'un step. Ses champs sont TOUS optionnels et l'objet ne porte
|
|
37
|
+
* PAS d'`additionalProperties` — voir l'en-tête : c'est ce sous-objet qui a révélé que la
|
|
38
|
+
* profondeur de retrait devait être décidée par schéma.
|
|
39
|
+
*/
|
|
40
|
+
const AddStepDataSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
text: z.string().describe('Step description.').optional(),
|
|
43
|
+
title: z.string().describe('Alias for text.').optional(),
|
|
44
|
+
assignee: z.string().describe('Optional assignee.').optional(),
|
|
45
|
+
estimated_effort: z
|
|
46
|
+
.number()
|
|
47
|
+
.describe('Step-level estimate in minutes (pln#495). A duration string like "2h"/"30m" is also accepted and coerced.')
|
|
48
|
+
.optional(),
|
|
49
|
+
actual_effort: z
|
|
50
|
+
.string()
|
|
51
|
+
.describe('Step-level actual effort, free-form ("45m", "2h"), parsed when the estimation report runs.')
|
|
52
|
+
.optional(),
|
|
53
|
+
})
|
|
54
|
+
.describe('Canonical step payload: { text, title?, assignee? }. title is accepted as an alias for text.');
|
|
55
|
+
export const AddStepRequestSchema = z.object({
|
|
56
|
+
planId: z.string().describe('Plan item ID.'),
|
|
57
|
+
data: AddStepDataSchema.optional(),
|
|
58
|
+
// Forme HISTORIQUE, conservée : elle est publiée et des appelants s'en servent.
|
|
59
|
+
text: z.string().describe('Legacy top-level step description; prefer data.text.').optional(),
|
|
60
|
+
...CallerIdentity,
|
|
61
|
+
assignee: z
|
|
62
|
+
.string()
|
|
63
|
+
.describe('Legacy top-level optional assignee; prefer data.assignee.')
|
|
64
|
+
.optional(),
|
|
65
|
+
project: z
|
|
66
|
+
.string()
|
|
67
|
+
.describe('Optional: name (or path/basename) of a linked project to add the step in. Defaults to the current project. Same resolution as canonical-grammar tools — accepts cross_project_links and workspace store-chain children.')
|
|
68
|
+
.optional(),
|
|
69
|
+
});
|
|
70
|
+
export const UpdateStepRequestSchema = z.object({
|
|
71
|
+
planId: z.string().describe('Plan item ID.'),
|
|
72
|
+
stepId: z.string().describe('Step ID to update.'),
|
|
73
|
+
// Chaîne libre, PAS un enum : les cinq valeurs sont documentées, pas imposées.
|
|
74
|
+
status: z
|
|
75
|
+
.string()
|
|
76
|
+
.describe('New status: todo, in_progress, testing, done, blocked.')
|
|
77
|
+
.optional(),
|
|
78
|
+
text: z.string().describe('New step text.').optional(),
|
|
79
|
+
assignee: z.string().describe('New assignee (empty string to unassign).').optional(),
|
|
80
|
+
estimated_effort: z
|
|
81
|
+
.number()
|
|
82
|
+
.describe('Step-level estimate in minutes (pln#495); a duration string is also coerced.')
|
|
83
|
+
.optional(),
|
|
84
|
+
actual_effort: z
|
|
85
|
+
.string()
|
|
86
|
+
.describe('Step-level actual effort, free-form ("45m", "2h").')
|
|
87
|
+
.optional(),
|
|
88
|
+
...CallerIdentity,
|
|
89
|
+
project: z
|
|
90
|
+
.string()
|
|
91
|
+
.describe('Optional: name of a linked project to update the step in. Defaults to the current project.')
|
|
92
|
+
.optional(),
|
|
93
|
+
});
|
|
94
|
+
export const CompleteStepRequestSchema = z.object({
|
|
95
|
+
planId: z.string().describe('Plan item ID.'),
|
|
96
|
+
stepId: z.string().describe('Step ID to complete.'),
|
|
97
|
+
...CallerIdentity,
|
|
98
|
+
project: z
|
|
99
|
+
.string()
|
|
100
|
+
.describe('Optional: name of a linked project to complete the step in. Defaults to the current project.')
|
|
101
|
+
.optional(),
|
|
102
|
+
});
|
|
103
|
+
export const DeleteStepRequestSchema = z.object({
|
|
104
|
+
planId: z.string().describe('Plan item ID.'),
|
|
105
|
+
stepId: z.string().describe('Step ID to delete.'),
|
|
106
|
+
...CallerIdentity,
|
|
107
|
+
project: z
|
|
108
|
+
.string()
|
|
109
|
+
.describe('Optional: name of a linked project to delete the step from. Defaults to the current project.')
|
|
110
|
+
.optional(),
|
|
111
|
+
});
|
|
112
|
+
//# sourceMappingURL=step-request-schema.js.map
|
|
@@ -102,11 +102,7 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
|
|
|
102
102
|
export function resolveEffectiveCwd(options = {}) {
|
|
103
103
|
return resolveEffectiveCwdInfo(options).cwd;
|
|
104
104
|
}
|
|
105
|
-
|
|
106
|
-
* Resolve the effective cwd and explain which selector won. Use this for MCP
|
|
107
|
-
* facades that must echo their project scope to avoid silent cross-project reads.
|
|
108
|
-
*/
|
|
109
|
-
export function resolveEffectiveCwdInfo(options = {}) {
|
|
105
|
+
function resolveEffectiveCwdInner(options, observed) {
|
|
110
106
|
const baseCwd = path.resolve(options.baseCwd ?? process.cwd());
|
|
111
107
|
// 1. Explicit --cwd flag
|
|
112
108
|
if (options.explicitCwd) {
|
|
@@ -192,9 +188,18 @@ export function resolveEffectiveCwdInfo(options = {}) {
|
|
|
192
188
|
// A named id is an exact-file lookup, so the record IS the one asked for; the
|
|
193
189
|
// pid check covers the unnamed case. Anything else is a weak adoption.
|
|
194
190
|
if (opts?.requireStrongIdentity && !explicitSessionId && session.pid !== process.pid) {
|
|
191
|
+
// Observe avant de rejeter : une session d'un AUTRE processus qui designe un autre
|
|
192
|
+
// projet est exactement le cas ou une ecriture peut partir ailleurs en silence.
|
|
193
|
+
const weak = session.active_project;
|
|
194
|
+
if (weak && !observed.sessionProject)
|
|
195
|
+
observed.sessionProject = { path: weak.path, name: weak.name };
|
|
195
196
|
return undefined;
|
|
196
197
|
}
|
|
197
198
|
const sp = session.active_project;
|
|
199
|
+
// Retenu AVANT le controle d'adoption : un record trouvable qui designe un projet
|
|
200
|
+
// compte comme observation meme quand il n'est pas retenu.
|
|
201
|
+
if (sp && !observed.sessionProject)
|
|
202
|
+
observed.sessionProject = { path: sp.path, name: sp.name };
|
|
198
203
|
if (sp && fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
|
|
199
204
|
return { cwd: sp.path, active_source: 'session', resolved_project: { path: sp.path, name: sp.name } };
|
|
200
205
|
}
|
|
@@ -287,6 +292,30 @@ export function resolveEffectiveCwdInfo(options = {}) {
|
|
|
287
292
|
// 7. Default
|
|
288
293
|
return { cwd: anchorCwd, active_source: 'cwd', resolved_project: projectInfo(anchorCwd) };
|
|
289
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Point d'entree unique de la resolution (pln#648 SUITE d).
|
|
297
|
+
*
|
|
298
|
+
* Enrichit le verdict d'un signal de divergence quand un record de session trouvable
|
|
299
|
+
* designait un AUTRE projet que celui retenu. Le calcul est purement local : la sonde a
|
|
300
|
+
* deja lu le record, aucune lecture disque n'est ajoutee.
|
|
301
|
+
*/
|
|
302
|
+
export function resolveEffectiveCwdInfo(options = {}) {
|
|
303
|
+
const observed = {};
|
|
304
|
+
const result = resolveEffectiveCwdInner(options, observed);
|
|
305
|
+
const seen = observed.sessionProject;
|
|
306
|
+
if (!seen || result.active_source === 'session')
|
|
307
|
+
return result;
|
|
308
|
+
if (path.resolve(seen.path) === path.resolve(result.cwd))
|
|
309
|
+
return result;
|
|
310
|
+
return {
|
|
311
|
+
...result,
|
|
312
|
+
session_divergence: {
|
|
313
|
+
session_project_path: seen.path,
|
|
314
|
+
session_project_name: seen.name,
|
|
315
|
+
resolved_via: result.active_source,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
290
319
|
function projectInfo(cwd) {
|
|
291
320
|
try {
|
|
292
321
|
const config = loadConfig(cwd);
|
package/dist/core/warnings.js
CHANGED
|
@@ -48,6 +48,43 @@ export function pushStructuredWarning(warnings, details, input) {
|
|
|
48
48
|
// Each owns its recovery path, which is the entire point of the structured
|
|
49
49
|
// channel: `scope_already_claimed` used to be a dead-end string; now it names
|
|
50
50
|
// the two calls that resolve it.
|
|
51
|
+
/**
|
|
52
|
+
* `autoExecute: true` sur `intent='consult'` — un no-op, dit sur le canal STRUCTURE
|
|
53
|
+
* (pln#626 phase 3).
|
|
54
|
+
*
|
|
55
|
+
* POURQUOI PAS UNE ERREUR DURE. Le plan laissait le choix entre refuser et implementer un
|
|
56
|
+
* vrai « consult run ». Refuser casserait des appelants existants pour un drapeau qui n'a
|
|
57
|
+
* jamais rien fait, et l'implementer est une decision produit — le plan lui-meme penche
|
|
58
|
+
* pour le spawn sur les cibles spawn-only, ce qui depasse une correction de surface.
|
|
59
|
+
*
|
|
60
|
+
* POURQUOI PAS SEULEMENT UN TEXTE. Phase 1 avait deja pousse un avertissement en texte
|
|
61
|
+
* libre, ce qui vaut mieux que le silence mais reste illisible pour une machine : un agent
|
|
62
|
+
* ne peut pas brancher dessus. Le code structure rend le refus DETECTABLE, et la
|
|
63
|
+
* next_action nomme les deux chemins qui spawnent reellement.
|
|
64
|
+
*
|
|
65
|
+
* C'est la moitie de la phase 3 qui ne demande aucun arbitrage : rendre le no-op
|
|
66
|
+
* observable. L'autre moitie — refuser ou spawner — reste au produit.
|
|
67
|
+
*/
|
|
68
|
+
export function consultAutoExecuteNoOpWarning() {
|
|
69
|
+
return {
|
|
70
|
+
code: 'auto_execute_ignored_on_consult',
|
|
71
|
+
message: "autoExecute has no effect on intent='consult': consult delivers the RFC to the target "
|
|
72
|
+
+ 'inbox(es) only and never spawns an agent — targets pick it up via their own bclaw_work.',
|
|
73
|
+
data: { intent: 'consult', auto_execute_honored: false },
|
|
74
|
+
next_actions: [
|
|
75
|
+
{
|
|
76
|
+
tool: 'bclaw_dispatch',
|
|
77
|
+
args: { intent: 'execute' },
|
|
78
|
+
when: 'to actually spawn workers on a sequence lane',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
tool: 'bclaw_coordinate',
|
|
82
|
+
args: { intent: 'assign' },
|
|
83
|
+
when: 'to hand one scope to one agent and have it started',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
51
88
|
export function agentValidationFailedWarning(input) {
|
|
52
89
|
return {
|
|
53
90
|
code: 'agent_validation_failed',
|