@trycore/spec-build-harness 0.10.0 → 0.11.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.
@@ -2,17 +2,54 @@
2
2
  // del catálogo v2 que exige POST /orchestrator/projects/{id}/import/bundle
3
3
  // (docs/superpowers/specs/2026-08-20-cliente-migrate-normalizacion-eventos-design.md).
4
4
  //
5
- // Catálogo v2 embebido desde trycore-ia-hub/backend/app/modules/orchestrator/
6
- // event_catalog.py (SCHEMAS/TIPOS_POR_AGREGADO), copiado 2026-08-20. Si el
7
- // catálogo del hub cambia, esta copia se desincroniza — no hay forma de
8
- // importarlo en build-time entre repos distintos.
5
+ // Catálogo v2 e invariantes embebidos desde trycore-ia-hub/backend/app/modules/
6
+ // orchestrator/ (event_catalog.py, domain.py, release_domain.py,
7
+ // release_gate_domain.py, front_domain.py, project_facts_domain.py,
8
+ // import_service.py), re-verificados 2026-08-28 contra el import real del primer piloto real
9
+ // (issue #40). Si el catálogo del hub cambia, esta copia se desincroniza — no
10
+ // hay forma de importarlo en build-time entre repos distintos.
9
11
  export const PHASE_ORDER = [
10
12
  'dor', 'change', 'red', 'green', 'refactor', 'smoke', 'api', 'data', 'dod', 'pr', 'archived',
11
13
  ];
14
+ /** Gates del inner loop que el reducer del slice acepta (hub domain.py:130-133);
15
+ * cualquier otro nombre revienta con «gate desconocido» (domain.py:422). */
16
+ export const SLICE_GATES = [
17
+ 'dor', 'coherence_link', 'tdd', 'journey_smoke', 'fidelity',
18
+ 'api', 'data', 'wiring_verified', 'dod',
19
+ ];
20
+ /** Los seis gates del Release Gate (hub release_gate_domain.py:20-22). El
21
+ * import del hub no los valida por nombre, pero `estado_de_gates` solo itera
22
+ * estos seis: un gate ajeno sería una fila muerta que nadie audita. */
23
+ export const RELEASE_GATES = [
24
+ 'security', 'smell', 'ux', 'coherence', 'stack_arch', 'integration',
25
+ ];
26
+ /** `_MAX_NOTE` del catálogo del hub (event_catalog.py:37): todo campo de texto
27
+ * libre (note/evidence/cause/reason/summary/motivo) rechaza con
28
+ * `string_too_long` por encima de 2000 caracteres. */
29
+ export const MAX_NOTE = 2000;
30
+ const TRUNCATION_MARKER = ' … [truncado por migración]';
31
+ /** Trunca una nota al límite del hub conservando un marcador de truncado
32
+ * (defecto 5 del primer piloto real: 6 entradas rechazadas por `string_too_long`). */
33
+ export function truncateNote(text) {
34
+ if (text.length <= MAX_NOTE)
35
+ return text;
36
+ return text.slice(0, MAX_NOTE - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
37
+ }
38
+ // Evidencia ACUÑADA de migración: el invariante 3 del hub (domain.py:424-425 y
39
+ // 452-453) exige evidence en todo veredicto/wiring passing, también en el
40
+ // histórico (validar_secuencia usa el reducer real, import_domain.py:126-172).
41
+ // El build-state.json legacy solo guarda booleanos, así que la evidencia
42
+ // declara honestamente su origen en vez de inventar una ejecución.
43
+ export const MIGRATION_GATE_EVIDENCE = 'veredicto original true en build-state.json del harness legacy; detalle en la nota del slice (evidencia acuñada por trycore-build migrate)';
44
+ export const MIGRATION_WIRING_EVIDENCE = 'status passing en build-state.json del harness legacy, sin evidencia textual registrada (acuñada por trycore-build migrate)';
45
+ export const MIGRATION_RELEASE_EVIDENCE = 'veredicto original true en releases[] de build-state.json del harness legacy (evidencia acuñada por trycore-build migrate)';
46
+ /** Tipos que ESTE normalizador emite por agregado (subset de lo que valida el
47
+ * hub). Lo usa `validateNormalizedBundle` como red contra bugs del emisor. */
12
48
  export const EVENT_TYPES_BY_AGGREGATE = {
13
49
  slice: new Set([
14
50
  'slice_opened', 'phase_advanced', 'gate_verdict', 'wiring_checklist_seeded',
15
- 'wiring_item_updated', 'progress_noted', 'slice_archived', 'slice_parked', 'slice_cancelled',
51
+ 'wiring_item_updated', 'progress_noted', 'slice_archived', 'slice_parked',
52
+ 'slice_escalated', 'slice_cancelled',
16
53
  ]),
17
54
  release: new Set(['release_opened', 'release_gate_verdict', 'release_closed']),
18
55
  front: new Set(['front_opened', 'front_member_added', 'front_drained', 'front_closed']),
@@ -37,6 +74,42 @@ function ensureStrictlyIncreasing(events) {
37
74
  return { ...ev, occurred_at: new Date(ms).toISOString() };
38
75
  });
39
76
  }
77
+ function firstNonBlankString(...values) {
78
+ for (const v of values) {
79
+ if (typeof v === 'string' && v.trim() !== '')
80
+ return v;
81
+ }
82
+ return null;
83
+ }
84
+ function hasArchivedAs(entry) {
85
+ return typeof entry.archived_as === 'string' && entry.archived_as.trim() !== '';
86
+ }
87
+ function closingKind(entry) {
88
+ // Defecto 3 (EP-039): `archived_as` presente ⇒ el slice CERRÓ aunque falte
89
+ // `phase` — sin el cierre completo queda «en construcción» en el hub,
90
+ // reclamable por la cola e irreversible tras el import (el guard
91
+ // anti-superposición, import_domain.py:175-192, rechaza re-importarlo).
92
+ if (entry.phase === 'archived' || hasArchivedAs(entry))
93
+ return 'archived';
94
+ // Defecto 4 (EP-037): el estado legacy aparca con `phase: "parked"`; el hub
95
+ // lo modela como status con su evento propio `slice_parked` (event_catalog.py:156-158).
96
+ if (entry.phase === 'parked' || entry.status === 'parked')
97
+ return 'parked';
98
+ if (entry.status === 'cancelled' || entry.phase === 'cancelled')
99
+ return 'cancelled';
100
+ return null;
101
+ }
102
+ /** Hasta qué índice de fase avanza esta entrada. `archived`/`archived_as`
103
+ * avanzan hasta `pr`: `slice_archived` exige estar en pr (domain.py:499-500). */
104
+ function pipelineTargetIdx(entry) {
105
+ const phase = typeof entry.phase === 'string' ? entry.phase : null;
106
+ if (phase && PHASE_ORDER.includes(phase)) {
107
+ return phase === 'archived' ? PHASE_ORDER.indexOf('pr') : PHASE_ORDER.indexOf(phase);
108
+ }
109
+ if (hasArchivedAs(entry))
110
+ return PHASE_ORDER.indexOf('pr');
111
+ return -1;
112
+ }
40
113
  function openingEvent(entry, epicCode) {
41
114
  return {
42
115
  event_type: 'slice_opened',
@@ -44,52 +117,116 @@ function openingEvent(entry, epicCode) {
44
117
  occurred_at: toIso(entry.updated_at, EPOCH_ISO),
45
118
  };
46
119
  }
47
- function phaseAdvancedEvents(entry) {
48
- const finalPhase = typeof entry.phase === 'string' ? entry.phase : null;
49
- const idx = finalPhase ? PHASE_ORDER.indexOf(finalPhase) : -1;
50
- if (idx <= 0)
120
+ function phaseAdvancedEvents(entry, state) {
121
+ const target = pipelineTargetIdx(entry);
122
+ if (target <= state.phaseIdx)
51
123
  return [];
52
- const upTo = finalPhase === 'archived' ? PHASE_ORDER.indexOf('pr') : idx;
53
124
  const anchor = toIso(entry.updated_at, EPOCH_ISO);
54
125
  const events = [];
55
- for (let i = 1; i <= upTo; i++) {
126
+ for (let i = state.phaseIdx + 1; i <= target; i++) {
56
127
  events.push({ event_type: 'phase_advanced', payload: { to: PHASE_ORDER[i] }, occurred_at: anchor });
57
128
  }
129
+ state.phaseIdx = target;
58
130
  return events;
59
131
  }
60
- function gateVerdictEvents(entry) {
132
+ function degradedGateNote(gate, reason, anchor) {
133
+ return {
134
+ event_type: 'progress_noted',
135
+ payload: {
136
+ note: truncateNote(`[migración] gate "${gate}"=true en build-state.json legacy no importable como veredicto: ${reason} — conservado como nota`),
137
+ },
138
+ occurred_at: anchor,
139
+ };
140
+ }
141
+ function gateVerdictEvents(entry, epicCode, state, warnings) {
61
142
  const gates = entry.gates && typeof entry.gates === 'object' ? entry.gates : {};
62
143
  const anchor = toIso(entry.updated_at, EPOCH_ISO);
63
- return Object.entries(gates)
64
- .filter(([, v]) => v === true)
65
- .map(([gate]) => ({ event_type: 'gate_verdict', payload: { gate, verdict: 'PASS' }, occurred_at: anchor }));
144
+ const events = [];
145
+ // Orden CANÓNICO del catálogo, no el del objeto legacy: wiring_verified va
146
+ // antes que dod en SLICE_GATES, que es justo lo que el invariante 4 exige.
147
+ for (const gate of SLICE_GATES) {
148
+ if (gates[gate] !== true)
149
+ continue;
150
+ if (gate === 'dod' && !state.gatesTrue.has('wiring_verified')) {
151
+ // Invariante 4 del hub (domain.py:427-428): dod PASS sin wiring_verified
152
+ // cerrado rechaza la ENTRADA entera. Se degrada solo el veredicto, sin
153
+ // acuñar un wiring_verified que nunca corrió (eso sería forjar un
154
+ // veredicto, no acuñar evidencia).
155
+ events.push(degradedGateNote('dod', 'wiring_verified nunca cerró en este slice (invariante 4 del hub)', anchor));
156
+ warnings.push(`history[${epicCode}]: dod=true sin wiring_verified — degradado a progress_noted (invariante 4 del hub); el gate dod queda abierto en el hub`);
157
+ continue;
158
+ }
159
+ events.push({
160
+ event_type: 'gate_verdict',
161
+ payload: { gate, verdict: 'PASS', evidence: MIGRATION_GATE_EVIDENCE },
162
+ occurred_at: anchor,
163
+ });
164
+ state.gatesTrue.add(gate);
165
+ }
166
+ // Defecto 8: gates legacy fuera del catálogo (caso real: `change`) revientan
167
+ // con «gate desconocido» (domain.py:422). Se degradan a progress_noted
168
+ // preservando el dato — nada se pierde, nada se forja.
169
+ for (const [gate, value] of Object.entries(gates)) {
170
+ if (value !== true || SLICE_GATES.includes(gate))
171
+ continue;
172
+ events.push(degradedGateNote(gate, 'no existe en el catálogo de gates del slice del hub', anchor));
173
+ warnings.push(`history[${epicCode}]: gate legacy "${gate}" fuera del catálogo del hub (${SLICE_GATES.join(', ')}) — degradado a progress_noted`);
174
+ }
175
+ return events;
176
+ }
177
+ /** Defecto 7 (EP-038): los estados legacy nombran el ítem con `item_id`, `id`
178
+ * o `item` según la era; item_id vacío revienta en el reducer (domain.py:450). */
179
+ function resolveWiringItemId(item) {
180
+ return String(firstNonBlankString(item.item_id, item.id, item.item) ?? '').trim();
66
181
  }
67
- function wiringEvents(entry) {
182
+ function wiringEvents(entry, epicCode, state, warnings, unmapped) {
68
183
  const checklist = Array.isArray(entry.wiring_checklist) ? entry.wiring_checklist : [];
69
184
  if (checklist.length === 0)
70
185
  return [];
71
186
  const anchor = toIso(entry.updated_at, EPOCH_ISO);
72
- const events = [
73
- {
187
+ const events = [];
188
+ const resolved = [];
189
+ checklist.forEach((rawItem, j) => {
190
+ const item = typeof rawItem === 'object' && rawItem !== null ? rawItem : {};
191
+ const id = resolveWiringItemId(item);
192
+ if (!id) {
193
+ warnings.push(`history[${epicCode}]: wiring_checklist[${j}] sin item_id/id/item resolvible — cae a unmapped (el hub exige item_id, domain.py:450)`);
194
+ unmapped.push({ source_key: `history[${epicCode}].wiring_checklist[${j}]`, original: rawItem, occurred_at: anchor });
195
+ return;
196
+ }
197
+ resolved.push({ id, item });
198
+ });
199
+ // La siembra solo declara ítems NUEVOS de la secuencia fusionada: re-sembrar
200
+ // un ítem lo devolvería a failing en el fold del hub (domain.py:471-484)
201
+ // pisando un passing anterior. Al menos un ítem (event_catalog.py:125).
202
+ const fresh = resolved.filter(({ id }) => !state.seededItemIds.has(id));
203
+ if (fresh.length > 0) {
204
+ events.push({
74
205
  event_type: 'wiring_checklist_seeded',
75
206
  payload: {
76
- items: checklist.map((item) => ({
77
- item_id: String(item.item_id ?? item.id ?? ''),
207
+ items: fresh.map(({ id, item }) => ({
208
+ item_id: id,
78
209
  kind: String(item.kind ?? 'unknown'),
79
210
  ref: String(item.ref ?? ''),
80
211
  })),
81
212
  },
82
213
  occurred_at: anchor,
83
- },
84
- ];
85
- for (const item of checklist) {
86
- if (typeof item.status === 'string') {
87
- events.push({
88
- event_type: 'wiring_item_updated',
89
- payload: { item_id: String(item.item_id ?? item.id ?? ''), status: item.status },
90
- occurred_at: anchor,
91
- });
214
+ });
215
+ fresh.forEach(({ id }) => state.seededItemIds.add(id));
216
+ }
217
+ for (const { id, item } of resolved) {
218
+ if (typeof item.status !== 'string')
219
+ continue;
220
+ const payload = { item_id: id, status: item.status };
221
+ const originalEvidence = firstNonBlankString(item.evidence);
222
+ if (item.status === 'passing') {
223
+ // Invariante 3 sobre wiring (domain.py:452-453): passing ⇒ evidence.
224
+ payload.evidence = originalEvidence ? truncateNote(originalEvidence) : MIGRATION_WIRING_EVIDENCE;
225
+ }
226
+ else if (originalEvidence) {
227
+ payload.evidence = truncateNote(originalEvidence);
92
228
  }
229
+ events.push({ event_type: 'wiring_item_updated', payload, occurred_at: anchor });
93
230
  }
94
231
  return events;
95
232
  }
@@ -98,21 +235,47 @@ function progressNotedEvents(entry) {
98
235
  const fallback = toIso(entry.updated_at, EPOCH_ISO);
99
236
  return log
100
237
  .filter((p) => typeof p === 'object' && p !== null && typeof p.note === 'string')
101
- .map((p) => ({ event_type: 'progress_noted', payload: { note: p.note }, occurred_at: toIso(p.at, fallback) }));
102
- }
103
- function bodyEvents(entry) {
104
- return [...phaseAdvancedEvents(entry), ...gateVerdictEvents(entry), ...wiringEvents(entry), ...progressNotedEvents(entry)];
238
+ .map((p) => ({
239
+ event_type: 'progress_noted',
240
+ // Defecto 5: notas >2000 revientan con string_too_long (_MAX_NOTE,
241
+ // event_catalog.py:37 y 134-135) — se truncan con marcador.
242
+ payload: { note: truncateNote(p.note) },
243
+ occurred_at: toIso(p.at, fallback),
244
+ }));
105
245
  }
106
- function closingEvent(entry) {
246
+ function closingEvents(entry, state) {
107
247
  const anchor = toIso(entry.updated_at, EPOCH_ISO);
108
- if (entry.phase === 'archived')
109
- return { event_type: 'slice_archived', payload: {}, occurred_at: anchor };
110
- if (entry.status === 'parked') {
111
- return { event_type: 'slice_parked', payload: { reason: String(entry.reason ?? entry.notes ?? 'sin motivo registrado') }, occurred_at: anchor };
248
+ const kind = closingKind(entry);
249
+ if (kind === 'archived') {
250
+ // slice_archived exige phase pr (domain.py:499-500); si el cuerpo no llegó
251
+ // (p. ej. archived_as sin phase con datos raros), se completa aquí.
252
+ const events = [];
253
+ const prIdx = PHASE_ORDER.indexOf('pr');
254
+ for (let i = state.phaseIdx + 1; i <= prIdx; i++) {
255
+ events.push({ event_type: 'phase_advanced', payload: { to: PHASE_ORDER[i] }, occurred_at: anchor });
256
+ }
257
+ state.phaseIdx = Math.max(state.phaseIdx, prIdx);
258
+ events.push({ event_type: 'slice_archived', payload: {}, occurred_at: anchor });
259
+ return events;
112
260
  }
113
- if (entry.status === 'cancelled')
114
- return { event_type: 'slice_cancelled', payload: {}, occurred_at: anchor };
115
- return null;
261
+ if (kind === 'parked') {
262
+ // slice_parked exige reason no vacía (domain.py:372-373) y ≤2000
263
+ // (event_catalog.py:156-158).
264
+ const reason = firstNonBlankString(entry.reason, entry.parked_reason, entry.notes)
265
+ ?? 'sin motivo registrado en build-state.json legacy (migración)';
266
+ return [{ event_type: 'slice_parked', payload: { reason: truncateNote(reason) }, occurred_at: anchor }];
267
+ }
268
+ if (kind === 'cancelled') {
269
+ // Cancelar exige pasar por ESCALATED (domain.py:393-394): el estado legacy
270
+ // cancelaba directo, así que se emite la escalada intermedia con su causa.
271
+ const cause = firstNonBlankString(entry.reason, entry.notes)
272
+ ?? 'cancelado en el harness legacy sin causa registrada (migración)';
273
+ return [
274
+ { event_type: 'slice_escalated', payload: { cause: truncateNote(cause) }, occurred_at: anchor },
275
+ { event_type: 'slice_cancelled', payload: {}, occurred_at: anchor },
276
+ ];
277
+ }
278
+ return [];
116
279
  }
117
280
  /**
118
281
  * Fusiona todas las entradas de `history[]` de UNA épica en una sola secuencia
@@ -120,36 +283,48 @@ function closingEvent(entry) {
120
283
  * entrada de MAYOR índice, nunca por updated_at — spec §6.1). El reducer del
121
284
  * servidor prohíbe reabrir un slice tras un cierre, así que las entradas
122
285
  * intermedias solo aportan su cuerpo (fases/gates/wiring/progreso), sin abrir
123
- * ni cerrar.
286
+ * ni cerrar; las fases NUNCA se re-avanzan (orden estricto, domain.py:404-406).
124
287
  */
125
- export function normalizeHistoryGroup(epicCode, entries) {
288
+ export function normalizeHistoryGroup(epicCode, entries, warnings = [], unmapped = []) {
126
289
  const objs = entries.filter((e) => typeof e === 'object' && e !== null && !Array.isArray(e));
127
290
  if (objs.length === 0)
128
291
  return null;
292
+ const state = { phaseIdx: 0, gatesTrue: new Set(), seededItemIds: new Set() };
129
293
  const events = [openingEvent(objs[0], epicCode)];
130
294
  objs.forEach((entry, i) => {
131
- events.push(...bodyEvents(entry));
132
- if (i === objs.length - 1) {
133
- const closing = closingEvent(entry);
134
- if (closing)
135
- events.push(closing);
295
+ if (hasArchivedAs(entry) && entry.phase !== 'archived') {
296
+ warnings.push(`history[${epicCode}]: archived_as presente sin phase "archived" — se emite el cierre completo (fases hasta pr + slice_archived); sin él, el slice quedaría reclamable en el hub y el import es irreversible (guard anti-superposición)`);
136
297
  }
298
+ events.push(...phaseAdvancedEvents(entry, state));
299
+ events.push(...gateVerdictEvents(entry, epicCode, state, warnings));
300
+ events.push(...wiringEvents(entry, epicCode, state, warnings, unmapped));
301
+ events.push(...progressNotedEvents(entry));
302
+ if (i === objs.length - 1)
303
+ events.push(...closingEvents(entry, state));
137
304
  });
138
305
  return { epic_code: epicCode, events: ensureStrictlyIncreasing(events), normalized: true };
139
306
  }
140
307
  /**
141
308
  * Agrupa history[] por épica preservando el ORDEN DE ÍNDICE de aparición
142
309
  * (nunca por updated_at — spec §6.1) y fusiona cada grupo en una entrada.
143
- * Entradas sin `epica` reconocible caen a unmapped, sin bloquear a las demás.
310
+ * La clave legacy `epic` se mapea con warning explícito (dentro de una entrada
311
+ * de history solo puede significar el código de épica — riesgo de falso
312
+ * positivo acotado y visible); sin clave reconocible cae a unmapped con warning.
144
313
  */
145
314
  export function normalizeHistory(historyRaw) {
146
315
  const groups = new Map();
147
316
  const order = [];
148
317
  const unmapped = [];
318
+ const warnings = [];
149
319
  historyRaw.forEach((raw, i) => {
150
320
  const obj = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : null;
151
- const epicCode = obj && typeof obj.epica === 'string' ? obj.epica : null;
321
+ let epicCode = obj && typeof obj.epica === 'string' && obj.epica !== '' ? obj.epica : null;
322
+ if (!epicCode && obj && typeof obj.epic === 'string' && obj.epic !== '') {
323
+ epicCode = obj.epic;
324
+ warnings.push(`history[${i}]: clave legacy "epic" — la clave esperada es "epica"; entrada mapeada a la épica "${obj.epic}"`);
325
+ }
152
326
  if (!epicCode) {
327
+ warnings.push(`history[${i}]: sin clave "epica" (ni la legacy "epic") reconocible — la entrada cae a unmapped`);
153
328
  unmapped.push({ source_key: `history[${i}]`, original: raw, occurred_at: toIso(obj?.updated_at, EPOCH_ISO) });
154
329
  return;
155
330
  }
@@ -161,21 +336,135 @@ export function normalizeHistory(historyRaw) {
161
336
  });
162
337
  const entries = [];
163
338
  for (const epicCode of order) {
164
- const merged = normalizeHistoryGroup(epicCode, groups.get(epicCode));
339
+ const merged = normalizeHistoryGroup(epicCode, groups.get(epicCode), warnings, unmapped);
165
340
  if (merged)
166
341
  entries.push(merged);
167
342
  }
168
- return { entries, unmapped };
343
+ return { entries, unmapped, warnings };
344
+ }
345
+ /** Plantilla del ACTA del workaround del piloto: honesta sobre el origen y
346
+ * sobre por qué los gates quedan sin declarar. */
347
+ export const PRE_HARNESS_ACTA = 'ACTA de migración (pre-arnés): épica construida antes de instalar el arnés; '
348
+ + 'verificación agregada (suite en verde + comportamiento en producción), gates sin declarar '
349
+ + 'a propósito — la distinción frente a las épicas archivadas con gates cerrados es deliberada. '
350
+ + 'Declarada por el humano en la lista de trycore-build migrate --pre-harness.';
351
+ /**
352
+ * Parsea y valida el fichero de --pre-harness: `{"epics": [{"code", "note"?}],
353
+ * "as_of"?}`. Estricto con las claves (una lista confirmada por un humano no
354
+ * admite typos silenciosos) y con duplicados. `as_of` opcional ancla los
355
+ * timestamps sintéticos; sin él se usa epoch — NUNCA el reloj (la
356
+ * normalización es determinista, misma regla que el resto de este fichero).
357
+ */
358
+ export function parsePreHarnessList(raw) {
359
+ const errors = [];
360
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
361
+ return { list: null, errors: ['el fichero pre-arnés no es un objeto JSON (forma esperada: {"epics": [{"code": "EP-001", "note": "…"}]})'] };
362
+ }
363
+ const obj = raw;
364
+ for (const key of Object.keys(obj)) {
365
+ if (key !== 'epics' && key !== 'as_of') {
366
+ errors.push(`clave desconocida en la raíz: "${key}" (solo se admiten "epics" y "as_of")`);
367
+ }
368
+ }
369
+ if (!Array.isArray(obj.epics)) {
370
+ errors.push('falta "epics" (array de épicas confirmadas por el humano)');
371
+ return { list: null, errors };
372
+ }
373
+ if (obj.epics.length === 0) {
374
+ errors.push('la lista "epics" está vacía — declara al menos una épica pre-arnés (o no uses --pre-harness)');
375
+ }
376
+ let asOf = EPOCH_ISO;
377
+ if (obj.as_of !== undefined) {
378
+ const d = typeof obj.as_of === 'string' ? new Date(obj.as_of) : new Date(NaN);
379
+ if (Number.isNaN(d.getTime())) {
380
+ errors.push(`"as_of" no es una fecha ISO válida: ${JSON.stringify(obj.as_of)}`);
381
+ }
382
+ else {
383
+ asOf = d.toISOString();
384
+ }
385
+ }
386
+ const epics = [];
387
+ const seen = new Set();
388
+ obj.epics.forEach((rawEpic, i) => {
389
+ if (typeof rawEpic !== 'object' || rawEpic === null || Array.isArray(rawEpic)) {
390
+ errors.push(`epics[${i}] no es un objeto (forma esperada: {"code": "EP-001", "note": "…"})`);
391
+ return;
392
+ }
393
+ const epic = rawEpic;
394
+ for (const key of Object.keys(epic)) {
395
+ if (key !== 'code' && key !== 'note') {
396
+ errors.push(`epics[${i}]: clave desconocida "${key}" (solo se admiten "code" y "note")`);
397
+ }
398
+ }
399
+ const code = typeof epic.code === 'string' ? epic.code.trim() : '';
400
+ if (!code) {
401
+ errors.push(`epics[${i}]: "code" vacío o ausente`);
402
+ return;
403
+ }
404
+ if (seen.has(code)) {
405
+ errors.push(`epics[${i}]: código duplicado en la lista: "${code}"`);
406
+ return;
407
+ }
408
+ seen.add(code);
409
+ let note;
410
+ if (epic.note !== undefined) {
411
+ if (typeof epic.note !== 'string' || epic.note.trim() === '') {
412
+ errors.push(`epics[${i}] (${code}): "note" debe ser texto no vacío — omítela si no aplica`);
413
+ }
414
+ else {
415
+ note = epic.note;
416
+ }
417
+ }
418
+ epics.push({ code, ...(note !== undefined ? { note } : {}) });
419
+ });
420
+ if (errors.length > 0)
421
+ return { list: null, errors };
422
+ return { list: { epics, asOf }, errors };
423
+ }
424
+ /**
425
+ * Genera la entrada de historia sintética de UNA épica pre-arnés: el patrón
426
+ * exacto del workaround — `slice_opened` → `phase_advanced` por cada fase
427
+ * hasta `pr` → `progress_noted` con el ACTA (incorporando la nota del humano
428
+ * si viene) → `slice_archived`. SIN `gate_verdict` alguno: declarar gates que
429
+ * nunca corrieron sería forjar veredictos, no migrar historia. Timestamps
430
+ * anclados en `anchorIso` y espaciados por `ensureStrictlyIncreasing` —
431
+ * deterministas, sin reloj.
432
+ */
433
+ export function normalizePreHarnessEpic(epic, anchorIso) {
434
+ const events = [
435
+ { event_type: 'slice_opened', payload: { epic_code: epic.code }, occurred_at: anchorIso },
436
+ ];
437
+ const prIdx = PHASE_ORDER.indexOf('pr');
438
+ for (let i = 1; i <= prIdx; i++) {
439
+ events.push({ event_type: 'phase_advanced', payload: { to: PHASE_ORDER[i] }, occurred_at: anchorIso });
440
+ }
441
+ const acta = epic.note ? `${PRE_HARNESS_ACTA} Nota del humano: ${epic.note}` : PRE_HARNESS_ACTA;
442
+ events.push({ event_type: 'progress_noted', payload: { note: truncateNote(acta) }, occurred_at: anchorIso });
443
+ events.push({ event_type: 'slice_archived', payload: {}, occurred_at: anchorIso });
444
+ return { epic_code: epic.code, events: ensureStrictlyIncreasing(events), normalized: true };
169
445
  }
170
- function releaseEvents(release, key) {
446
+ // ─── releases[] → agregado `release` ──────────────────────────────────────────
447
+ function releaseEvents(release, key, warnings) {
171
448
  const anchor = toIso(release.created_at, EPOCH_ISO);
172
449
  const epicCodes = Array.isArray(release.epicas) ? release.epicas.map(String) : [];
173
450
  const events = [
174
451
  { event_type: 'release_opened', payload: { release_line: key, epic_codes: epicCodes }, occurred_at: anchor },
175
452
  ];
453
+ const unmapped = [];
176
454
  const gates = release.gates && typeof release.gates === 'object' ? release.gates : {};
177
455
  for (const [gate, verdict] of Object.entries(gates)) {
178
- events.push({ event_type: 'release_gate_verdict', payload: { gate, verdict: verdict === true ? 'PASS' : 'FAIL' }, occurred_at: anchor });
456
+ if (!RELEASE_GATES.includes(gate)) {
457
+ warnings.push(`releases[${key}]: gate "${gate}" fuera del Release Gate del hub (${RELEASE_GATES.join(', ')}) — preservado en unmapped`);
458
+ unmapped.push({ source_key: `releases[${key}].gates[${gate}]`, original: { gate, value: verdict }, occurred_at: anchor });
459
+ continue;
460
+ }
461
+ const payload = { gate, verdict: verdict === true ? 'PASS' : 'FAIL' };
462
+ // Defecto 6: el fold del hub degrada PASS-sin-evidencia a BLOQUEANTE
463
+ // (release_gate_domain.py:93-96), irreparable retroactivamente — misma
464
+ // acuñación de evidencia de migración que en los gates del slice.
465
+ if (verdict === true)
466
+ payload.evidence = MIGRATION_RELEASE_EVIDENCE;
467
+ events.push({ event_type: 'release_gate_verdict', payload, occurred_at: anchor });
179
468
  }
180
469
  if (release.status === 'failed' || release.status === 'passed') {
181
470
  events.push({
@@ -185,35 +474,40 @@ function releaseEvents(release, key) {
185
474
  });
186
475
  }
187
476
  const notes = Array.isArray(release.notes) ? release.notes : [];
188
- const unmapped = notes.map((note, i) => ({
189
- source_key: `releases[${key}].notes[${i}]`,
190
- original: note,
191
- occurred_at: anchor,
192
- }));
477
+ notes.forEach((note, i) => {
478
+ unmapped.push({ source_key: `releases[${key}].notes[${i}]`, original: note, occurred_at: anchor });
479
+ });
193
480
  return { events: ensureStrictlyIncreasing(events), unmapped };
194
481
  }
195
482
  /** `notes[]` de un release no tiene tipo en el catálogo del agregado `release`
196
483
  * (event_catalog.py no declara ninguno) — se preserva en `unmapped[]` en vez
197
- * de descartarse (spec §7: "nada se pierde, nada bloquea"). Mismo principio
198
- * para una entrada sin `release_id` utilizable como key: cae a unmapped en
199
- * vez de descartarse en silencio (consistente con normalizeHistory). */
484
+ * de descartarse (spec §7: "nada se pierde, nada bloquea"). La clave legacy
485
+ * `release` se mapea a `release_id` con warning explícito; una entrada sin
486
+ * ninguna de las dos cae a unmapped con warning, nunca en silencio. */
200
487
  export function normalizeReleases(releasesRaw) {
201
488
  const entries = [];
202
489
  const unmapped = [];
490
+ const warnings = [];
203
491
  releasesRaw.forEach((raw, i) => {
204
492
  const obj = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : null;
205
- const key = obj && typeof obj.release_id === 'string' ? obj.release_id : null;
493
+ let key = obj && typeof obj.release_id === 'string' && obj.release_id !== '' ? obj.release_id : null;
494
+ if (!key && obj && typeof obj.release === 'string' && obj.release !== '') {
495
+ key = obj.release;
496
+ warnings.push(`releases[${i}]: clave legacy "release" — la clave esperada es "release_id"; entrada mapeada a "${obj.release}"`);
497
+ }
206
498
  if (!obj || !key) {
499
+ warnings.push(`releases[${i}]: sin clave "release_id" (ni la legacy "release") reconocible — la entrada cae a unmapped`);
207
500
  unmapped.push({ source_key: `releases[${i}]`, original: raw, occurred_at: toIso(obj?.created_at, EPOCH_ISO) });
208
501
  return;
209
502
  }
210
- const { events, unmapped: relUnmapped } = releaseEvents(obj, key);
503
+ const { events, unmapped: relUnmapped } = releaseEvents(obj, key, warnings);
211
504
  entries.push({ key, events, normalized: true });
212
505
  unmapped.push(...relUnmapped);
213
506
  });
214
- return { entries, unmapped };
507
+ return { entries, unmapped, warnings };
215
508
  }
216
- export function normalizeFront(frontRaw) {
509
+ // ─── parallel_front → agregado `front` ────────────────────────────────────────
510
+ export function normalizeFront(frontRaw, warnings = [], unmapped = []) {
217
511
  if (typeof frontRaw !== 'object' || frontRaw === null)
218
512
  return null;
219
513
  const front = frontRaw;
@@ -234,24 +528,124 @@ export function normalizeFront(frontRaw) {
234
528
  occurred_at: anchor,
235
529
  },
236
530
  ];
237
- if (front.status === 'drained')
531
+ if (front.status === 'drained') {
238
532
  events.push({ event_type: 'front_drained', payload: {}, occurred_at: anchor });
239
- else if (front.status === 'closed')
240
- events.push({ event_type: 'front_closed', payload: {}, occurred_at: anchor });
533
+ }
534
+ else if (front.status === 'closed') {
535
+ // front_closed exige DRAINING previo Y todos los miembros integrados con
536
+ // front_member_integrated (front_domain.py:382-398); el estado legacy no
537
+ // registra esas integraciones y forjarlas inventaría merges. Se importa
538
+ // hasta DRAINING y el cierre queda preservado en unmapped.
539
+ events.push({ event_type: 'front_drained', payload: {}, occurred_at: anchor });
540
+ warnings.push('parallel_front: status "closed" no importable sin las integraciones por miembro que exige el hub (front_domain.py:387-398) — se importa hasta DRAINING; el cierre queda en unmapped');
541
+ unmapped.push({ source_key: 'parallel_front.status', original: 'closed', occurred_at: anchor });
542
+ }
241
543
  return { key: 'parallel_front', events: ensureStrictlyIncreasing(events), normalized: true };
242
544
  }
243
- const FACT_KEYS = ['scaffold', 'design_source', 'project_kind', 'project_kind_source', 'foundation', 'harness_phase'];
244
- export function normalizeFacts(raw, fallbackAt) {
545
+ // ─── hechos de proyecto agregado `project` ──────────────────────────────────
546
+ // Catálogo de hechos del hub (project_facts_domain.py:27-37, FACT_A_COLUMNA):
547
+ // un `fact` fuera de esta lista se rechaza por entrada (import_service.py:449-453).
548
+ const HUB_FACT_RULES = {
549
+ project_kind: { kind: 'machine_text', max: 20 },
550
+ harness_phase: { kind: 'machine_text', max: 50 },
551
+ foundation: { kind: 'boolean' },
552
+ scaffold_confirmed: { kind: 'boolean' },
553
+ design_source_confirmed: { kind: 'boolean' },
554
+ design_source_ref: { kind: 'string_or_null' },
555
+ };
556
+ function confirmedBoolean(value) {
557
+ if (typeof value === 'boolean')
558
+ return value;
559
+ if (typeof value === 'object' && value !== null && typeof value.confirmed === 'boolean') {
560
+ return value.confirmed;
561
+ }
562
+ return null;
563
+ }
564
+ function confirmedAt(value, fallback) {
565
+ if (typeof value === 'object' && value !== null)
566
+ return toIso(value.confirmed_at, fallback);
567
+ return fallback;
568
+ }
569
+ /** Mapea los hechos del build-state.json legacy al catálogo del hub
570
+ * (FACT_A_COLUMNA): `scaffold.confirmed`→`scaffold_confirmed`,
571
+ * `design_source.{confirmed,source}`→`design_source_{confirmed,ref}`. Lo que
572
+ * el hub no conoce (`project_kind_source`) cae a unmapped con warning en vez
573
+ * de viajar y rechazarse como «hecho de proyecto desconocido». Busca cada
574
+ * clave en la raíz y, si falta, en `raw.facts` (forma de estados intermedios). */
575
+ export function normalizeFacts(raw, fallbackAt, warnings = [], unmapped = []) {
576
+ const nested = raw.facts && typeof raw.facts === 'object' && !Array.isArray(raw.facts)
577
+ ? raw.facts
578
+ : {};
579
+ const lookup = (key) => (key in raw ? raw[key] : nested[key]);
580
+ const has = (key) => key in raw || key in nested;
245
581
  const facts = [];
246
- for (const key of FACT_KEYS) {
247
- if (!(key in raw))
582
+ const push = (fact, value, occurredAt) => {
583
+ facts.push({ fact, value, occurred_at: occurredAt, normalized: true });
584
+ };
585
+ const drop = (key, value, reason) => {
586
+ warnings.push(`facts: "${key}" ${reason} — cae a unmapped`);
587
+ unmapped.push({ source_key: `facts[${key}]`, original: value, occurred_at: fallbackAt });
588
+ };
589
+ for (const key of ['project_kind', 'harness_phase']) {
590
+ if (!has(key))
248
591
  continue;
249
- const value = raw[key];
250
- const normalizedValue = typeof value === 'boolean' || typeof value === 'string' ? value : value === null || value === undefined ? null : JSON.stringify(value);
251
- facts.push({ fact: key, value: normalizedValue, occurred_at: fallbackAt, normalized: true });
592
+ const value = lookup(key);
593
+ if (value === null || value === undefined)
594
+ continue;
595
+ const max = HUB_FACT_RULES[key].max;
596
+ if (typeof value !== 'string' || value === '' || value.length > max) {
597
+ drop(key, value, `no cabe en su columna del hub (texto no vacío de hasta ${max} caracteres, project_facts_domain.py:58-62)`);
598
+ continue;
599
+ }
600
+ push(key, value, fallbackAt);
601
+ }
602
+ if (has('foundation')) {
603
+ const value = lookup('foundation');
604
+ const confirmed = confirmedBoolean(value);
605
+ if (confirmed === null) {
606
+ if (value !== null && value !== undefined) {
607
+ drop('foundation', value, 'no es booleano ni trae confirmed booleano (el hub exige booleano, project_facts_domain.py:56-57)');
608
+ }
609
+ }
610
+ else {
611
+ push('foundation', confirmed, confirmedAt(value, fallbackAt));
612
+ }
613
+ }
614
+ if (has('scaffold')) {
615
+ const value = lookup('scaffold');
616
+ const confirmed = confirmedBoolean(value);
617
+ if (confirmed === null) {
618
+ if (value !== null && value !== undefined)
619
+ drop('scaffold', value, 'sin confirmed booleano mapeable a scaffold_confirmed');
620
+ }
621
+ else {
622
+ push('scaffold_confirmed', confirmed, confirmedAt(value, fallbackAt));
623
+ }
624
+ }
625
+ if (has('design_source')) {
626
+ const value = lookup('design_source');
627
+ const confirmed = confirmedBoolean(value);
628
+ const at = confirmedAt(value, fallbackAt);
629
+ if (confirmed !== null)
630
+ push('design_source_confirmed', confirmed, at);
631
+ const ref = typeof value === 'object' && value !== null
632
+ ? firstNonBlankString(value.source, value.ref)
633
+ : null;
634
+ if (ref)
635
+ push('design_source_ref', ref, at);
636
+ if (confirmed === null && !ref && value !== null && value !== undefined) {
637
+ drop('design_source', value, 'sin confirmed booleano ni source mapeables');
638
+ }
639
+ }
640
+ if (has('project_kind_source')) {
641
+ const value = lookup('project_kind_source');
642
+ if (value !== null && value !== undefined) {
643
+ drop('project_kind_source', value, 'no existe en el catálogo de hechos del hub (FACT_A_COLUMNA, project_facts_domain.py:27-37)');
644
+ }
252
645
  }
253
646
  return facts;
254
647
  }
648
+ // ─── validación local del emisor (red contra bugs del normalizador) ───────────
255
649
  function checkSequence(errors, aggregate, key, events) {
256
650
  if (events.length === 0)
257
651
  errors.push(`${key}: sin eventos`);
@@ -265,8 +659,8 @@ function checkSequence(errors, aggregate, key, events) {
265
659
  }
266
660
  }
267
661
  /** Validación local previa al volcado (spec §9): mismo catálogo que valida el
268
- * servidor, sin replicar la forma de payload campo a campo (eso lo cubre el
269
- * test golden, no una revalidación en runtime). */
662
+ * servidor, sin replicar la forma de payload campo a campo (eso lo cubre
663
+ * `verifyNormalizedBundle`, que re-implementa los invariantes del hub). */
270
664
  export function validateNormalizedBundle(bundle) {
271
665
  const errors = [];
272
666
  bundle.history.forEach((h) => checkSequence(errors, 'slice', `history[${h.epic_code}]`, h.events));
@@ -274,3 +668,456 @@ export function validateNormalizedBundle(bundle) {
274
668
  bundle.fronts.forEach((f) => checkSequence(errors, 'front', `fronts[${f.key}]`, f.events));
275
669
  return errors;
276
670
  }
671
+ // ─── verify: los invariantes del hub, offline (migrate --verify) ──────────────
672
+ //
673
+ // Réplica en seco de lo que el hub aplica en el import (`validar_secuencia`
674
+ // conduce el reducer REAL de cada agregado, import_domain.py:126-172): la misma
675
+ // entrada que rechazaría el servidor debe salir aquí ANTES de entregar el
676
+ // bundle al ADMIN. Cada regla cita el punto del hub que la exige.
677
+ /** Tipos por agregado tal como los declara el hub (event_catalog.py:522-594,
678
+ * TIPOS_POR_AGREGADO) — la enumeración del catálogo es cerrada. */
679
+ const HUB_TYPES = {
680
+ slice: new Set([
681
+ 'slice_opened', 'slice_claimed', 'phase_advanced', 'phase_reverted', 'gate_verdict',
682
+ 'wiring_item_updated', 'wiring_checklist_seeded', 'checkpoint_recorded', 'progress_noted',
683
+ 'handoff_recorded', 'slice_submitted', 'slice_archived', 'slice_reflected', 'slice_parked',
684
+ 'slice_resumed', 'slice_escalated', 'slice_requeued', 'slice_cancelled', 'lease_renewed',
685
+ 'lease_expired', 'lease_reaped', 'context_drift_detected', 'context_synced',
686
+ ]),
687
+ release: new Set(['release_opened', 'release_gate_verdict', 'release_closed']),
688
+ front: new Set(['front_opened', 'front_member_added', 'front_member_integrated', 'front_drained', 'front_closed']),
689
+ };
690
+ /** Claves de payload que admite cada schema del catálogo: `extra="forbid"`
691
+ * (event_catalog.py:52-53) rechaza el evento entero ante una clave no
692
+ * declarada. Los `null` no viajan (payload_sin_nulos, import_domain.py:120-123). */
693
+ const HUB_PAYLOAD_KEYS = {
694
+ slice_opened: new Set(['epic_id', 'epic_code', 'retries_budget', 'source']),
695
+ slice_claimed: new Set(['agent_id', 'manifest_hash']),
696
+ phase_advanced: new Set(['to', 'source']),
697
+ phase_reverted: new Set(['to', 'gate', 'source']),
698
+ gate_verdict: new Set(['gate', 'verdict', 'evidence', 'cause', 'details_ref', 'source']),
699
+ wiring_item_updated: new Set(['item_id', 'status', 'evidence', 'kind', 'ref', 'source']),
700
+ wiring_checklist_seeded: new Set(['items', 'source']),
701
+ checkpoint_recorded: new Set(['branch', 'commit_sha', 'summary', 'source']),
702
+ progress_noted: new Set(['note', 'source']),
703
+ handoff_recorded: new Set(['note', 'resume_hint', 'stopped_at', 'source']),
704
+ slice_submitted: new Set([]),
705
+ slice_archived: new Set([]),
706
+ slice_parked: new Set(['reason']),
707
+ slice_resumed: new Set(['retries_budget']),
708
+ slice_escalated: new Set(['cause']),
709
+ slice_requeued: new Set(['retries_budget']),
710
+ slice_cancelled: new Set([]),
711
+ release_opened: new Set(['release_line', 'epic_codes']),
712
+ release_gate_verdict: new Set(['gate', 'verdict', 'evidence']),
713
+ release_closed: new Set(['status', 'motivo', 'epic_codes']),
714
+ front_opened: new Set(['members', 'merge_order', 'asignaciones']),
715
+ front_member_added: new Set(['epic_code', 'merge_order', 'worktree', 'assignee']),
716
+ front_member_integrated: new Set(['epic_code', 'merge_commit', 'resmoke_ok', 'evidence']),
717
+ front_drained: new Set([]),
718
+ front_closed: new Set([]),
719
+ };
720
+ const RELEASE_VERDICTS = new Set(['PASS', 'FAIL', 'PARTIAL', 'TOOL_ERROR']);
721
+ function isBlank(value) {
722
+ return typeof value !== 'string' || value.trim() === '';
723
+ }
724
+ function checkNoteLimit(add, label, value) {
725
+ if (typeof value === 'string' && value.length > MAX_NOTE) {
726
+ add(`${label} supera el límite de ${MAX_NOTE} caracteres del hub (string_too_long; _MAX_NOTE, event_catalog.py:37)`);
727
+ }
728
+ }
729
+ function checkCommon(add, aggregate, i, ev) {
730
+ if (!HUB_TYPES[aggregate].has(ev.event_type)) {
731
+ add(`evento ${i}: "${ev.event_type}" no es un tipo del agregado ${aggregate} (la enumeración del catálogo es cerrada, import_domain.py:144-149)`);
732
+ return false;
733
+ }
734
+ if (Number.isNaN(new Date(ev.occurred_at).getTime())) {
735
+ add(`evento ${i} (${ev.event_type}): occurred_at inválido: "${ev.occurred_at}"`);
736
+ }
737
+ const allowed = HUB_PAYLOAD_KEYS[ev.event_type];
738
+ if (allowed) {
739
+ for (const key of Object.keys(ev.payload ?? {})) {
740
+ if (ev.payload[key] === null)
741
+ continue;
742
+ if (!allowed.has(key)) {
743
+ add(`evento ${i} (${ev.event_type}): clave "${key}" fuera del schema del catálogo (extra="forbid", event_catalog.py:52-53)`);
744
+ }
745
+ }
746
+ }
747
+ return true;
748
+ }
749
+ function verifySliceSequence(events, add) {
750
+ if (events.length === 0) {
751
+ add('sin eventos: el hub no tiene nada que importar');
752
+ return;
753
+ }
754
+ let phase = 'dor';
755
+ let status = 'ACTIVE';
756
+ const gatesTrue = new Set();
757
+ const rejected = new Set();
758
+ const isTerminal = () => status === 'CANCELLED' || phase === 'archived';
759
+ events.forEach((ev, i) => {
760
+ const payload = (ev.payload ?? {});
761
+ if (!checkCommon(add, 'slice', i, ev))
762
+ return;
763
+ const t = ev.event_type;
764
+ if (i === 0 && t !== 'slice_opened') {
765
+ add(`evento 0: el primer evento debe ser slice_opened, no ${t} (domain.py:287)`);
766
+ }
767
+ if (i > 0 && t === 'slice_opened') {
768
+ add(`evento ${i}: slice_opened solo puede ser el primer evento (domain.py:299)`);
769
+ return;
770
+ }
771
+ if (t === 'slice_opened' || t === 'lease_reaped')
772
+ return;
773
+ if (isTerminal() && t !== 'slice_archived' && t !== 'slice_reflected') {
774
+ add(`evento ${i} (${t}): ilegal sobre un slice terminal — en terminal solo aplican slice_archived, slice_reflected y lease_reaped (domain.py:318-324)`);
775
+ return;
776
+ }
777
+ switch (t) {
778
+ case 'phase_advanced': {
779
+ const to = payload.to;
780
+ const idx = PHASE_ORDER.indexOf(phase);
781
+ const next = idx + 1 < PHASE_ORDER.length ? PHASE_ORDER[idx + 1] : null;
782
+ if (status !== 'ACTIVE')
783
+ add(`evento ${i}: solo un slice ACTIVE avanza de fase (status=${status}, domain.py:397-398)`);
784
+ if (to !== next) {
785
+ add(`evento ${i}: orden estricto de fases (invariante 1, domain.py:404-406): ${phase} → ${String(to)} es ilegal; siguiente válida: ${String(next)}`);
786
+ }
787
+ if (typeof to === 'string' && PHASE_ORDER.includes(to))
788
+ phase = to;
789
+ break;
790
+ }
791
+ case 'phase_reverted': {
792
+ const to = payload.to;
793
+ const gate = payload.gate;
794
+ if (typeof to !== 'string' || !PHASE_ORDER.includes(to) || PHASE_ORDER.indexOf(to) >= PHASE_ORDER.indexOf(phase)) {
795
+ add(`evento ${i}: phase_reverted debe retroceder a una fase anterior (domain.py:412-413)`);
796
+ }
797
+ else {
798
+ phase = to;
799
+ }
800
+ if (typeof gate !== 'string' || !rejected.has(gate)) {
801
+ add(`evento ${i}: retroceso solo por rechazo de gate explícito (invariante 1, domain.py:414-416): el gate ${JSON.stringify(gate)} no tiene un FAIL registrado`);
802
+ }
803
+ break;
804
+ }
805
+ case 'gate_verdict': {
806
+ const gate = String(payload.gate ?? '');
807
+ const verdict = payload.verdict;
808
+ if (status !== 'ACTIVE')
809
+ add(`evento ${i}: solo un slice ACTIVE procesa gate_verdict (status=${status}, domain.py:397-398)`);
810
+ if (!SLICE_GATES.includes(gate)) {
811
+ add(`evento ${i}: gate desconocido "${gate}" — el reducer del slice solo acepta ${SLICE_GATES.join(', ')} (domain.py:422)`);
812
+ break;
813
+ }
814
+ checkNoteLimit(add, `evento ${i} (gate_verdict): evidence`, payload.evidence);
815
+ checkNoteLimit(add, `evento ${i} (gate_verdict): cause`, payload.cause);
816
+ if (verdict === 'PASS') {
817
+ if (isBlank(payload.evidence)) {
818
+ add(`evento ${i}: gate_verdict PASS de "${gate}" sin evidence — passing ⇒ evidence obligatoria (invariante 3, domain.py:424-425)`);
819
+ }
820
+ if (gate === 'dod' && !gatesTrue.has('wiring_verified')) {
821
+ add(`evento ${i}: dod PASS sin wiring_verified cerrado — wiring_verified es prerequisito duro de dod (invariante 4, domain.py:427-428)`);
822
+ }
823
+ gatesTrue.add(gate);
824
+ rejected.delete(gate);
825
+ }
826
+ else if (verdict === 'FAIL') {
827
+ if (gatesTrue.has(gate) && isBlank(payload.cause)) {
828
+ add(`evento ${i}: tumbar el gate cerrado "${gate}" exige cause (invariante 2, ratchet, domain.py:432-434)`);
829
+ }
830
+ gatesTrue.delete(gate);
831
+ rejected.add(gate);
832
+ }
833
+ else {
834
+ add(`evento ${i}: veredicto ${JSON.stringify(verdict)} no promueve el gate "${gate}": parcial o fallo de herramienta es bloqueante (invariante 5, domain.py:441-445)`);
835
+ }
836
+ break;
837
+ }
838
+ case 'wiring_item_updated': {
839
+ if (status !== 'ACTIVE')
840
+ add(`evento ${i}: solo un slice ACTIVE procesa wiring_item_updated (status=${status}, domain.py:397-398)`);
841
+ if (isBlank(payload.item_id)) {
842
+ add(`evento ${i}: wiring_item_updated exige item_id no vacío (domain.py:450)`);
843
+ }
844
+ checkNoteLimit(add, `evento ${i} (wiring_item_updated): evidence`, payload.evidence);
845
+ if (payload.status === 'passing' && isBlank(payload.evidence)) {
846
+ add(`evento ${i}: wiring passing sin evidence — passing ⇒ evidence obligatoria (invariante 3, domain.py:452-453)`);
847
+ }
848
+ break;
849
+ }
850
+ case 'wiring_checklist_seeded': {
851
+ const items = Array.isArray(payload.items) ? payload.items : [];
852
+ if (items.length === 0) {
853
+ add(`evento ${i}: wiring_checklist_seeded exige al menos un ítem (event_catalog.py:125)`);
854
+ }
855
+ items.forEach((item, j) => {
856
+ const it = typeof item === 'object' && item !== null ? item : {};
857
+ for (const field of ['item_id', 'kind', 'ref']) {
858
+ if (typeof it[field] !== 'string') {
859
+ add(`evento ${i}: items[${j}] sin "${field}" (los tres campos son exigidos, event_catalog.py:111-118)`);
860
+ }
861
+ }
862
+ });
863
+ break;
864
+ }
865
+ case 'progress_noted': {
866
+ if (typeof payload.note !== 'string')
867
+ add(`evento ${i}: progress_noted exige note (event_catalog.py:134-135)`);
868
+ checkNoteLimit(add, `evento ${i} (progress_noted): note`, payload.note);
869
+ break;
870
+ }
871
+ case 'handoff_recorded': {
872
+ if (typeof payload.note !== 'string')
873
+ add(`evento ${i}: handoff_recorded exige note (event_catalog.py:138-139)`);
874
+ checkNoteLimit(add, `evento ${i} (handoff_recorded): note`, payload.note);
875
+ checkNoteLimit(add, `evento ${i} (handoff_recorded): resume_hint`, payload.resume_hint);
876
+ checkNoteLimit(add, `evento ${i} (handoff_recorded): stopped_at`, payload.stopped_at);
877
+ break;
878
+ }
879
+ case 'checkpoint_recorded': {
880
+ if (typeof payload.branch !== 'string' || typeof payload.commit_sha !== 'string') {
881
+ add(`evento ${i}: checkpoint_recorded exige branch y commit_sha (event_catalog.py:128-131)`);
882
+ }
883
+ checkNoteLimit(add, `evento ${i} (checkpoint_recorded): summary`, payload.summary);
884
+ break;
885
+ }
886
+ case 'slice_parked': {
887
+ if (status !== 'ACTIVE')
888
+ add(`evento ${i}: no se puede aparcar desde ${status} (domain.py:368)`);
889
+ if (isBlank(payload.reason))
890
+ add(`evento ${i}: slice_parked exige reason no vacía (invariante 7, domain.py:372-373)`);
891
+ checkNoteLimit(add, `evento ${i} (slice_parked): reason`, payload.reason);
892
+ status = 'PARKED';
893
+ break;
894
+ }
895
+ case 'slice_resumed': {
896
+ if (status !== 'PARKED')
897
+ add(`evento ${i}: slice_resumed solo aplica a un slice PARKED (domain.py:376)`);
898
+ status = 'ACTIVE';
899
+ break;
900
+ }
901
+ case 'slice_escalated': {
902
+ if (status !== 'ACTIVE' && status !== 'PARKED')
903
+ add(`evento ${i}: no se escala desde ${status} (domain.py:383)`);
904
+ if (isBlank(payload.cause))
905
+ add(`evento ${i}: slice_escalated exige cause no vacía (domain.py:384-385)`);
906
+ checkNoteLimit(add, `evento ${i} (slice_escalated): cause`, payload.cause);
907
+ status = 'ESCALATED';
908
+ break;
909
+ }
910
+ case 'slice_requeued': {
911
+ if (status !== 'ESCALATED')
912
+ add(`evento ${i}: slice_requeued solo aplica a ESCALATED (domain.py:388)`);
913
+ status = 'ACTIVE';
914
+ break;
915
+ }
916
+ case 'slice_cancelled': {
917
+ if (status !== 'ESCALATED')
918
+ add(`evento ${i}: cancelar exige pasar por ESCALATED (domain.py:393-394)`);
919
+ status = 'CANCELLED';
920
+ break;
921
+ }
922
+ case 'slice_submitted': {
923
+ if (!gatesTrue.has('dod'))
924
+ add(`evento ${i}: submit exige el gate dod cerrado (domain.py:487-488)`);
925
+ break;
926
+ }
927
+ case 'slice_archived': {
928
+ if (phase !== 'pr')
929
+ add(`evento ${i}: archivar exige estar en la fase pr (domain.py:499-500), no en ${phase}`);
930
+ phase = 'archived';
931
+ break;
932
+ }
933
+ default:
934
+ // Telemetría/lease/contexto: hechos anotables, no transiciones (domain.py:326-364).
935
+ break;
936
+ }
937
+ });
938
+ }
939
+ function verifyReleaseSequence(events, add) {
940
+ if (events.length === 0) {
941
+ add('sin eventos: el hub no tiene nada que importar');
942
+ return;
943
+ }
944
+ let closed = false;
945
+ events.forEach((ev, i) => {
946
+ const payload = (ev.payload ?? {});
947
+ if (!checkCommon(add, 'release', i, ev))
948
+ return;
949
+ const t = ev.event_type;
950
+ if (i === 0) {
951
+ if (t !== 'release_opened') {
952
+ add(`evento 0: el primer evento del agregado release debe ser release_opened, no ${t} (release_domain.py:124-127)`);
953
+ }
954
+ else if (isBlank(payload.release_line)) {
955
+ add('evento 0: release_opened exige release_line (event_catalog.py:327; el import deriva de ahí la fila de identidad, import_service.py:349-360)');
956
+ }
957
+ return;
958
+ }
959
+ if (t === 'release_opened') {
960
+ add(`evento ${i}: release_opened solo puede ser el primer evento (release_domain.py:151)`);
961
+ return;
962
+ }
963
+ if (closed) {
964
+ add(`evento ${i} (${t}): ilegal sobre una release ya cerrada (release_domain.py:155-160)`);
965
+ return;
966
+ }
967
+ if (t === 'release_gate_verdict') {
968
+ const gate = String(payload.gate ?? '');
969
+ const verdict = payload.verdict;
970
+ if (!RELEASE_GATES.includes(gate)) {
971
+ add(`evento ${i}: gate "${gate}" no pertenece al Release Gate (${RELEASE_GATES.join(', ')}, release_gate_domain.py:20-22 y 68-71)`);
972
+ }
973
+ if (typeof verdict !== 'string' || !RELEASE_VERDICTS.has(verdict)) {
974
+ add(`evento ${i}: veredicto ${JSON.stringify(verdict)} desconocido: PASS | FAIL | PARTIAL | TOOL_ERROR (release_gate_domain.py:32 y 72-75)`);
975
+ }
976
+ checkNoteLimit(add, `evento ${i} (release_gate_verdict): evidence`, payload.evidence);
977
+ if (verdict === 'PASS' && isBlank(payload.evidence)) {
978
+ add(`evento ${i}: release_gate_verdict PASS de "${gate}" sin evidence — el fold del hub lo degrada a BLOQUEANTE, irreparable retroactivamente (release_gate_domain.py:93-96)`);
979
+ }
980
+ }
981
+ else if (t === 'release_closed') {
982
+ const st = payload.status;
983
+ if (st !== 'PASSED' && st !== 'FAILED') {
984
+ add(`evento ${i}: cerrar una release exige PASSED o FAILED, no ${JSON.stringify(st)} (release_domain.py:179-181)`);
985
+ }
986
+ checkNoteLimit(add, `evento ${i} (release_closed): motivo`, payload.motivo);
987
+ closed = true;
988
+ }
989
+ });
990
+ }
991
+ function verifyFrontSequence(events, add) {
992
+ if (events.length === 0) {
993
+ add('sin eventos: el hub no tiene nada que importar');
994
+ return;
995
+ }
996
+ let frontStatus = 'ACTIVE';
997
+ const memberCodes = new Set();
998
+ const integrated = new Set();
999
+ events.forEach((ev, i) => {
1000
+ const payload = (ev.payload ?? {});
1001
+ if (!checkCommon(add, 'front', i, ev))
1002
+ return;
1003
+ const t = ev.event_type;
1004
+ if (i === 0) {
1005
+ if (t !== 'front_opened') {
1006
+ add(`evento 0: el primer evento del agregado front debe ser front_opened, no ${t} (front_domain.py:296-300)`);
1007
+ }
1008
+ else {
1009
+ (Array.isArray(payload.members) ? payload.members : []).forEach((m) => memberCodes.add(String(m)));
1010
+ }
1011
+ return;
1012
+ }
1013
+ if (t === 'front_opened') {
1014
+ add(`evento ${i}: front_opened solo puede ser el primer evento (front_domain.py:330)`);
1015
+ return;
1016
+ }
1017
+ if (frontStatus === 'CLOSED') {
1018
+ add(`evento ${i} (${t}): ilegal sobre un front cerrado (front_domain.py:331-334)`);
1019
+ return;
1020
+ }
1021
+ if (t === 'front_member_added') {
1022
+ const code = String(payload.epic_code ?? '');
1023
+ if (frontStatus !== 'ACTIVE')
1024
+ add(`evento ${i}: un front ${frontStatus} no admite miembros nuevos (front_domain.py:337-340)`);
1025
+ if (memberCodes.has(code))
1026
+ add(`evento ${i}: ${code} ya es miembro de este front (front_domain.py:342-345)`);
1027
+ memberCodes.add(code);
1028
+ }
1029
+ else if (t === 'front_member_integrated') {
1030
+ const code = String(payload.epic_code ?? '');
1031
+ if (!memberCodes.has(code))
1032
+ add(`evento ${i}: ${code} no es miembro de este front (front_domain.py:367-368)`);
1033
+ checkNoteLimit(add, `evento ${i} (front_member_integrated): evidence`, payload.evidence);
1034
+ if (payload.resmoke_ok === true)
1035
+ integrated.add(code);
1036
+ }
1037
+ else if (t === 'front_drained') {
1038
+ if (frontStatus !== 'ACTIVE')
1039
+ add(`evento ${i}: un front ${frontStatus} no se puede drenar (front_domain.py:382-383)`);
1040
+ frontStatus = 'DRAINING';
1041
+ }
1042
+ else if (t === 'front_closed') {
1043
+ if (frontStatus !== 'DRAINING')
1044
+ add(`evento ${i}: cerrar exige drenar primero (ACTIVE → DRAINING → CLOSED, front_domain.py:387-390)`);
1045
+ const pending = [...memberCodes].filter((c) => !integrated.has(c));
1046
+ if (pending.length > 0)
1047
+ add(`evento ${i}: quedan miembros sin integrar: ${pending.join(', ')} (front_domain.py:391-397)`);
1048
+ frontStatus = 'CLOSED';
1049
+ }
1050
+ });
1051
+ }
1052
+ function verifyFact(fact, add) {
1053
+ const rule = HUB_FACT_RULES[fact.fact];
1054
+ if (!rule) {
1055
+ add(`hecho de proyecto desconocido: "${fact.fact}" (conocidos: ${Object.keys(HUB_FACT_RULES).sort().join(', ')}; import_service.py:449-453)`);
1056
+ return;
1057
+ }
1058
+ if (Number.isNaN(new Date(fact.occurred_at).getTime())) {
1059
+ add(`occurred_at inválido: "${fact.occurred_at}"`);
1060
+ }
1061
+ const v = fact.value;
1062
+ if (rule.kind === 'boolean' && typeof v !== 'boolean') {
1063
+ add(`"${fact.fact}" es booleano en el hub (project_facts_domain.py:56-57, import_service.py:458-460)`);
1064
+ }
1065
+ else if (rule.kind === 'machine_text' && (typeof v !== 'string' || v === '' || v.length > (rule.max ?? MAX_NOTE))) {
1066
+ add(`"${fact.fact}" es texto no vacío de hasta ${rule.max} caracteres en el hub (project_facts_domain.py:47 y 58-62)`);
1067
+ }
1068
+ else if (rule.kind === 'string_or_null' && !(v === null || typeof v === 'string')) {
1069
+ add(`"${fact.fact}" es texto (o null) en el hub (import_service.py:461-462)`);
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Valida el bundle YA normalizado contra los invariantes del hub SIN red:
1074
+ * evidence obligatoria en passing (invariante 3), límites `_MAX_NOTE`,
1075
+ * catálogo cerrado de gates y eventos, item_id no vacío, orden estricto de
1076
+ * fases, wiring_verified→dod (invariante 4), máquinas de estados de
1077
+ * slice/release/front y catálogo de hechos de proyecto. Devuelve las
1078
+ * violaciones con su entrada de origen; vacío = el hub no tendría motivo de
1079
+ * rechazo conocido.
1080
+ */
1081
+ export function verifyNormalizedBundle(bundle) {
1082
+ const violations = [];
1083
+ const scoped = (source_key) => (message) => {
1084
+ violations.push({ source_key, message });
1085
+ };
1086
+ const seenKeys = new Set();
1087
+ const checkDuplicate = (key) => {
1088
+ // El import rechaza la clave de entrada duplicada (import_service.py:128-137).
1089
+ if (seenKeys.has(key))
1090
+ violations.push({ source_key: key, message: 'clave de entrada duplicada en el bundle (import_service.py:128-137)' });
1091
+ seenKeys.add(key);
1092
+ };
1093
+ for (const h of bundle.history) {
1094
+ const key = `history[${h.epic_code}]`;
1095
+ checkDuplicate(key);
1096
+ verifySliceSequence(h.events, scoped(key));
1097
+ }
1098
+ for (const r of bundle.releases) {
1099
+ const key = `releases[${r.key}]`;
1100
+ checkDuplicate(key);
1101
+ verifyReleaseSequence(r.events, scoped(key));
1102
+ }
1103
+ for (const f of bundle.fronts) {
1104
+ const key = `fronts[${f.key}]`;
1105
+ checkDuplicate(key);
1106
+ verifyFrontSequence(f.events, scoped(key));
1107
+ }
1108
+ for (const fact of bundle.facts) {
1109
+ const key = `facts[${fact.fact}]`;
1110
+ checkDuplicate(key);
1111
+ verifyFact(fact, scoped(key));
1112
+ }
1113
+ (bundle.unmapped ?? []).forEach((u, i) => {
1114
+ // LegacyImported exige source_key de 1..200 caracteres (event_catalog.py:446).
1115
+ if (typeof u.source_key !== 'string' || u.source_key.length < 1 || u.source_key.length > 200) {
1116
+ violations.push({
1117
+ source_key: `unmapped[${i}]`,
1118
+ message: 'source_key debe tener entre 1 y 200 caracteres (LegacyImported, event_catalog.py:446)',
1119
+ });
1120
+ }
1121
+ });
1122
+ return violations;
1123
+ }