@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.
@@ -50,10 +50,16 @@ printf 'allow-list-v1' > "$PROJ/.claude/config/stack-allowlist.json"
50
50
  runtime_lock_write "sha256:v1" '[{"path":"config/stack-allowlist.json","sha256":"sha-del-servidor"},{"path":"rules/no-existe.md","sha256":"otro"}]'
51
51
  got="$(ops_context_hashes)"
52
52
  real="$(python3 -c "import hashlib;print(hashlib.sha256(open('$PROJ/.claude/config/stack-allowlist.json','rb').read()).hexdigest())")"
53
- echo "$got" | grep -q "$real" && echo "OK ops_context_hashes reporta el sha local efectivo" || { echo "FAIL ops_context_hashes ($got)"; fail=1; }
54
- echo "$got" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("SI" if any(x["path"]=="rules/no-existe.md" and x["sha256"] is None for x in d) else "NO")' | grep -q SI \
55
- && echo "OK ops_context_hashes marca null el fichero ausente" || { echo "FAIL ops_context_hashes ausente"; fail=1; }
56
- [ "$(PATH=/var/empty ops_context_hashes)" = "[]" ] && echo "OK ops_context_hashes sin python3 -> []" || { echo "FAIL ops_context_hashes sin python3"; fail=1; }
53
+ # Contrato del hub (#36): `TasksNextIn.context_hashes` es `dict[str, str]` una LISTA
54
+ # provoca 422 dict_type en TODO claim. Se exige {path: sha}, jamás [{"path":…}].
55
+ echo "$got" | python3 -c "
56
+ import json,sys
57
+ d=json.load(sys.stdin)
58
+ print('SI' if isinstance(d,dict) and d.get('config/stack-allowlist.json')=='$real' else 'NO')" | grep -q SI \
59
+ && echo "OK ops_context_hashes emite dict {path: sha} con el sha local efectivo" || { echo "FAIL ops_context_hashes ($got)"; fail=1; }
60
+ echo "$got" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("SI" if d.get("rules/no-existe.md")=="" else "NO")' | grep -q SI \
61
+ && echo "OK ops_context_hashes marca \"\" el fichero ausente (el dict del hub no admite null)" || { echo "FAIL ops_context_hashes ausente ($got)"; fail=1; }
62
+ [ "$(PATH=/var/empty ops_context_hashes)" = "{}" ] && echo "OK ops_context_hashes sin python3 -> {}" || { echo "FAIL ops_context_hashes sin python3"; fail=1; }
57
63
 
58
64
  # --- ops_enqueue_from_file: payload por FICHERO (sin límite de ARG_MAX) ---
59
65
  python3 -c "
@@ -70,14 +76,27 @@ ok = d['type']=='wiring_checklist_seeded' and d.get('slice_id')=='sl-1' and len(
70
76
  print('SI' if ok else 'NO')" | grep -q SI && echo "OK ops_enqueue_from_file conserva tipo/slice_id/payload" || { echo "FAIL contenido del evento encolado"; fail=1; }
71
77
  rm -f "$PROJ/.claude/state/outbox/"*.json
72
78
 
73
- # --- lista protegida ampliada: la cota jamás descarta un hecho de dominio ---
74
- for t in slice_archived wiring_item_updated escalation_raised scaffold_confirmed design_source_declared project_kind_declared foundation_declared harness_phase_changed wiring_checklist_seeded front_integration_reported; do
79
+ # --- lista protegida: nombres del catálogo v2 del hub [#44] ---
80
+ # (#38: los hechos máquina viajan como UN solo tipo del catálogo v2, project_fact_updated;
81
+ # #44: los tipos protegidos llevan el NOMBRE del catálogo — proteger de la evicción un tipo
82
+ # que el hub va a rechazar era absurdo. Los alias 0.10.x de los cuatro renombrados siguen
83
+ # protegidos porque la capa de compat del despacho los traduce y entrega.)
84
+ for t in slice_archived wiring_item_updated slice_escalated gate_verdict checkpoint_recorded \
85
+ project_fact_updated wiring_checklist_seeded slice_submitted handoff_recorded telemetry_gap \
86
+ checkpoint_created verdict_reported escalation_raised; do
75
87
  case " $RUNTIME_OUTBOX_PROTECTED " in
76
88
  *" $t "*) : ;;
77
89
  *) echo "FAIL $t no está en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
78
90
  esac
79
91
  done
80
- echo "OK lista protegida ampliada (si no hubo FAIL arriba)"
92
+ # Tipos sin equivalente en el catálogo: fuera de la lista (el hub los rechazaría siempre).
93
+ for t in front_integration_reported branch_drift tool_use_recorded progress_noted progress_note_recorded; do
94
+ case " $RUNTIME_OUTBOX_PROTECTED " in
95
+ *" $t "*) echo "FAIL $t no debe estar en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
96
+ *) : ;;
97
+ esac
98
+ done
99
+ echo "OK lista protegida con nombres del catálogo v2 (si no hubo FAIL arriba)"
81
100
  # Regresión de comportamiento (no de la constante): 6 MB de tool_use + 1 slice_archived.
82
101
  runtime_enqueue_event slice_archived '{"epic_code":"EP-001"}' sl-1
83
102
  python3 - "$PROJ/.claude/state/outbox" <<'PY'
@@ -139,7 +158,18 @@ STUBS="$STUBS $!"
139
158
  sleep 0.3
140
159
  runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\",\"project_token\":\"tok-c\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\",\"manifest_hash\":\"sha256:v1\"}"
141
160
 
142
- rc=0; out="$(bash "$SO" claim --epic EP-003)" || rc=$?
161
+ # (#39) `claim --epic` NO existe en el protocolo del hub: `TasksNextIn` no declara
162
+ # epic_code (pydantic lo ignoraba y la cola repartía OTRA épica con claim "exitoso").
163
+ # Debe fallar EXPLÍCITO antes de tocar la red.
164
+ : > "$TMP/calls-cl.jsonl"
165
+ rc=0; err="$(bash "$SO" claim --epic EP-003 2>&1 >/dev/null)" || rc=$?
166
+ [ "$rc" = 2 ] && echo "OK claim --epic en runtime -> rc 2 (uso, sin red)" || { echo "FAIL claim --epic rc ($rc)"; fail=1; }
167
+ echo "$err" | grep -qi "cola" && echo "$err" | grep -q "epic" \
168
+ && echo "OK claim --epic explica que el hub reparte por orden de cola" || { echo "FAIL mensaje de claim --epic ($err)"; fail=1; }
169
+ grep -q '"path": "/tasks/next"' "$TMP/calls-cl.jsonl" && { echo "FAIL claim --epic llamó a la red"; fail=1; } \
170
+ || echo "OK claim --epic no abre socket"
171
+
172
+ rc=0; out="$(bash "$SO" claim)" || rc=$?
143
173
  [ "$rc" = 0 ] && echo "OK claim 200 -> rc 0" || { echo "FAIL claim 200 rc ($rc)"; fail=1; }
144
174
  echo "$out" | grep -q "sl-7" && echo "OK claim rinde el slice reclamado" || { echo "FAIL claim render ($out)"; fail=1; }
145
175
  [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
@@ -150,10 +180,12 @@ for line in open(sys.argv[1]):
150
180
  if c["path"]=="/tasks/next":
151
181
  b=json.loads(c["body"])
152
182
  # POST siempre: el GET no acepta el reporte de hashes (spec §3.1 rev. 4).
153
- ok = c["method"]=="POST" and isinstance(b.get("context_hashes"), list) and b.get("epic_code")=="EP-003"
183
+ # (#36) context_hashes es DICT (ClaimIn del hub: dict[str,str]); (#39) epic_code
184
+ # jamás viaja — el hub no lo declara y lo ignoraría en silencio.
185
+ ok = c["method"]=="POST" and isinstance(b.get("context_hashes"), dict) and "epic_code" not in b
154
186
  print("SI" if ok else "NO")
155
187
  PYX
156
- )" = SI ] && echo "OK el claim usa POST y reporta context_hashes y la épica" || { echo "FAIL cuerpo/método del claim"; fail=1; }
188
+ )" = SI ] && echo "OK el claim usa POST con context_hashes dict y sin epic_code" || { echo "FAIL cuerpo/método del claim"; fail=1; }
157
189
  [ "$(projection_get active_slice.slice_id "")" = "sl-7" ] && echo "OK el claim refresca la caché de proyección" || { echo "FAIL la caché no se refrescó"; fail=1; }
158
190
 
159
191
  # next-step: deriva del cliente (spec §2), prioridad resume_hint > wiring > sub-slices > gate.
@@ -245,26 +277,59 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
245
277
  # ══════════════ slice-ops.sh: fact / propose-asset [Task 5] ══════════════
246
278
  rm -f "$PROJ/.claude/state/outbox/"*.json
247
279
 
248
- # Los hechos de proyecto son de ÁMBITO PROYECTO: se encolan SIN slice_id (§3.1 rev. 3).
249
- rc=0; bash "$SO" fact scaffold-confirmed --by "ana@equipo" --notes "npm run dev arranca vacío" >/dev/null || rc=$?
250
- [ "$rc" = 0 ] && echo "OK fact scaffold-confirmed -> rc 0" || { echo "FAIL fact scaffold rc ($rc)"; fail=1; }
251
- f="$(grep -l '"type": "scaffold_confirmed"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
252
- [ -n "$f" ] && [ "$(python3 -c "import json;print('slice_id' in json.load(open('$f')))")" = False ] \
253
- && echo "OK el hecho de proyecto va SIN slice_id" || { echo "FAIL scaffold_confirmed con slice_id"; fail=1; }
254
- [ "$(python3 -c "import json;print(json.load(open('$f'))['payload']['confirmed_by'])")" = "ana@equipo" ] \
255
- && echo "OK scaffold_confirmed lleva quién confirmó" || { echo "FAIL payload de scaffold_confirmed"; fail=1; }
256
-
257
- rc=0; bash "$SO" fact design-source --applies true --source "docs/05-prototipo/index.html" --confirmed true --by "ana@equipo" >/dev/null || rc=$?
258
- [ "$rc" = 0 ] && grep -lq '"type": "design_source_declared"' "$PROJ/.claude/state/outbox/"*.json \
259
- && echo "OK fact design-source" || { echo "FAIL fact design-source ($rc)"; fail=1; }
280
+ # (#38) Hechos HUMANOS: el catálogo v2 del hub los rechaza en TODA superficie de agente
281
+ # (HECHOS_HUMANOS en project_facts_domain) se fijan tras el PDP por la superficie admin
282
+ # (PATCH /orchestrator/projects/{project_id}). En runtime/dual NO se encola: rechazo local
283
+ # con mensaje que dirige al humano a la consola del hub.
284
+ rc=0; err="$(bash "$SO" fact scaffold-confirmed --by "ana@equipo" --notes "npm run dev arranca vacío" 2>&1 >/dev/null)" || rc=$?
285
+ [ "$rc" = 6 ] && echo "OK fact scaffold-confirmed en runtime -> rc 6 (hecho humano)" || { echo "FAIL fact scaffold rc ($rc)"; fail=1; }
286
+ echo "$err" | grep -q "PATCH /orchestrator/projects" \
287
+ && echo "OK el rechazo del hecho humano nombra la superficie admin" || { echo "FAIL mensaje de scaffold-confirmed ($err)"; fail=1; }
288
+ [ "$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')" = 0 ] \
289
+ && echo "OK el hecho humano NO se encola (adiós reintento eterno)" || { echo "FAIL scaffold-confirmed encoló"; fail=1; }
290
+
291
+ rc=0; err="$(bash "$SO" fact design-source --applies true --source "docs/05-prototipo/index.html" --confirmed true --by "ana@equipo" 2>&1 >/dev/null)" || rc=$?
292
+ [ "$rc" = 6 ] && echo "$err" | grep -q "PATCH /orchestrator/projects" \
293
+ && echo "OK fact design-source en runtime -> rc 6 con remisión al admin" || { echo "FAIL fact design-source ($rc: $err)"; fail=1; }
294
+
295
+ # (#38) Hechos MÁQUINA: UN solo tipo del catálogo v2, `project_fact_updated`, con el
296
+ # payload EXACTO {fact, value, source:"AGENT"} (extra="forbid" en el hub), SIN slice_id.
260
297
  rc=0; bash "$SO" fact project-kind greenfield --source human >/dev/null || rc=$?
261
- [ "$rc" = 0 ] && grep -lq '"type": "project_kind_declared"' "$PROJ/.claude/state/outbox/"*.json \
262
- && echo "OK fact project-kind" || { echo "FAIL fact project-kind ($rc)"; fail=1; }
298
+ [ "$rc" = 0 ] && echo "OK fact project-kind -> rc 0" || { echo "FAIL fact project-kind rc ($rc)"; fail=1; }
299
+ f="$(grep -l '"fact": "project_kind"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
300
+ [ -n "$f" ] && [ "$(python3 - "$f" <<'PYX'
301
+ import json,sys
302
+ d=json.load(open(sys.argv[1]))
303
+ ok = d.get("type")=="project_fact_updated" and "slice_id" not in d \
304
+ and d.get("payload")=={"fact":"project_kind","value":"greenfield","source":"AGENT"}
305
+ print("SI" if ok else "NO")
306
+ PYX
307
+ )" = SI ] \
308
+ && echo "OK project-kind viaja como project_fact_updated {fact,value,source} sin slice_id" \
309
+ || { echo "FAIL payload de project-kind"; fail=1; }
310
+
263
311
  echo '[{"id":"homepage","applies":true},{"id":"login-authn","applies":false}]' > "$TMP/checklist.json"
264
312
  rc=0; bash "$SO" fact foundation --required true --epic EP-000 --checklist-file "$TMP/checklist.json" >/dev/null || rc=$?
265
- f="$(grep -l '"type": "foundation_declared"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
266
- [ "$rc" = 0 ] && [ -n "$f" ] && [ "$(python3 -c "import json;print(len(json.load(open('$f'))['payload']['checklist']))")" = 2 ] \
267
- && echo "OK fact foundation con checklist" || { echo "FAIL fact foundation"; fail=1; }
313
+ f="$(grep -l '"fact": "foundation"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
314
+ # El hub proyecta foundation a un Boolean: el value es bool, sin epic/checklist extra
315
+ # (extra=forbid los rechazaría); la checklist queda en los docs locales.
316
+ [ "$rc" = 0 ] && [ -n "$f" ] && [ "$(python3 - "$f" <<'PYX'
317
+ import json,sys
318
+ p=json.load(open(sys.argv[1]))["payload"]
319
+ print("SI" if p=={"fact":"foundation","value":True,"source":"AGENT"} else "NO")
320
+ PYX
321
+ )" = SI ] \
322
+ && echo "OK fact foundation viaja como project_fact_updated con value booleano" || { echo "FAIL fact foundation"; fail=1; }
323
+
324
+ rc=0; bash "$SO" fact harness-phase building >/dev/null || rc=$?
325
+ f="$(grep -l '"fact": "harness_phase"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
326
+ [ "$rc" = 0 ] && [ -n "$f" ] && [ "$(python3 - "$f" <<'PYX'
327
+ import json,sys
328
+ p=json.load(open(sys.argv[1]))["payload"]
329
+ print("SI" if p=={"fact":"harness_phase","value":"building","source":"AGENT"} else "NO")
330
+ PYX
331
+ )" = SI ] \
332
+ && echo "OK fact harness-phase viaja como project_fact_updated" || { echo "FAIL fact harness-phase"; fail=1; }
268
333
 
269
334
  # Valor inválido: rechazo local, sin encolar.
270
335
  rm -f "$PROJ/.claude/state/outbox/"*.json
@@ -278,10 +343,10 @@ rc=0; bash "$SO" fact project-kind marciano >/dev/null 2>&1 || rc=$?
278
343
  # outbox y confirmamos que mkdir/tempfile fallan de verdad antes de aserir el rc.
279
344
  rm -f "$PROJ/.claude/state/outbox/"*.json
280
345
  chmod 555 "$PROJ/.claude/state/outbox"
281
- rc=0; out="$(bash "$SO" fact scaffold-confirmed --by "ana@equipo" 2>&1)" || rc=$?
346
+ rc=0; out="$(bash "$SO" fact project-kind greenfield 2>&1)" || rc=$?
282
347
  chmod 755 "$PROJ/.claude/state/outbox"
283
348
  n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
284
- [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q '"scaffold_confirmed" encolado' \
349
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'encolado' \
285
350
  && echo "OK fact con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
286
351
  || { echo "FAIL fact con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
287
352
  rm -f "$PROJ/.claude/state/outbox/"*.json
@@ -336,12 +401,18 @@ rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" status --no-refresh)" || rc=
336
401
  agent_context_fetch_and_cache
337
402
 
338
403
  # escalate: evento tipado + instrucción de gobierno humano.
404
+ # [#44] El catálogo v2 lo llama `slice_escalated` y su payload es EXACTAMENTE {cause}
405
+ # (extra="forbid" en el hub): gate y fase viajan DENTRO del texto de la causa.
339
406
  rm -f "$PROJ/.claude/state/outbox/"*.json
340
407
  rc=0; out="$(bash "$SO" escalate "el AC-3 contradice el prototipo" --gate fidelity)" || rc=$?
341
408
  [ "$rc" = 0 ] && echo "OK escalate -> rc 0" || { echo "FAIL escalate rc ($rc)"; fail=1; }
342
- f="$(grep -l '"type": "escalation_raised"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
343
- [ -n "$f" ] && [ "$(python3 -c "import json;d=json.load(open('$f'));print('SI' if d['payload']['gate']=='fidelity' and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
344
- && echo "OK escalate encola con gate y slice_id" || { echo "FAIL payload de escalate"; fail=1; }
409
+ f="$(grep -l '"type": "slice_escalated"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
410
+ [ -n "$f" ] && [ "$(python3 -c "
411
+ import json;d=json.load(open('$f'))
412
+ p=d['payload']
413
+ ok = set(p.keys())=={'cause'} and 'fidelity' in p['cause'] and 'AC-3' in p['cause'] and d.get('slice_id')=='sl-7'
414
+ print('SI' if ok else 'NO')")" = SI ] \
415
+ && echo "OK escalate encola slice_escalated {cause} (gate y fase dentro del texto) con slice_id" || { echo "FAIL payload de escalate"; fail=1; }
345
416
  echo "$out" | grep -qi "human" && echo "OK escalate declara que la decisión es humana" || { echo "FAIL escalate sin nota de gobierno"; fail=1; }
346
417
  rm -f "$PROJ/.claude/state/outbox/"*.json
347
418
 
@@ -462,12 +533,26 @@ for sub in "verdict R1-mvp security pass" "front-integration fr-1 --merge-status
462
533
  done
463
534
  [ ! -s "$TMP/curl-legacy.log" ] && echo "OK release-ops no toca la red en legacy" || { echo "FAIL release-ops abrió socket en legacy"; fail=1; }
464
535
 
465
- # Offline: el veredicto de release se encola (protegido como verdict_reported).
536
+ # Offline: el veredicto de release NO se encola [#44] `release_gate_verdict` es del
537
+ # agregado `release` y POST /events solo ingiere ámbito slice (slice_id obligatorio,
538
+ # progress_service._apply_and_append) o proyecto: encolarlo era rechazo garantizado al
539
+ # drenar. rc 5 con instrucción de reintentar al reconectar (mismo patrón que propose-asset).
466
540
  runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
467
541
  rm -f "$PROJ/.claude/state/outbox/"*.json
468
- rc=0; bash "$RO" verdict R1-mvp smell fail --evidence "3 smells" >/dev/null 2>&1 || rc=$?
469
- [ "$rc" = 5 ] && grep -lq '"type": "verdict_reported"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
470
- && echo "OK verdict de release offline se encola" || { echo "FAIL verdict de release offline ($rc)"; fail=1; }
542
+ rc=0; err="$(bash "$RO" verdict R1-mvp smell fail --evidence "3 smells" 2>&1 >/dev/null)" || rc=$?
543
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
544
+ [ "$rc" = 5 ] && [ "$n" = 0 ] && echo "$err" | grep -qi "reintenta" \
545
+ && echo "OK verdict de release offline -> rc 5 sin encolar, con instrucción de reintento" \
546
+ || { echo "FAIL verdict de release offline (rc=$rc n=$n err=$err)"; fail=1; }
547
+
548
+ # Offline: el reporte de integración de front tampoco se encola [#44] —
549
+ # `front_integration_reported` no existe en el catálogo v2 y su superficie síncrona en el
550
+ # hub es de gobierno humano (`can_govern_front`); un evento encolado jamás entraría.
551
+ rc=0; err="$(bash "$RO" front-integration fr-1 --merge-status merged --resmoke pass --evidence "x" 2>&1 >/dev/null)" || rc=$?
552
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
553
+ [ "$rc" = 5 ] && [ "$n" = 0 ] && echo "$err" | grep -qi "reintenta" \
554
+ && echo "OK front-integration offline -> rc 5 sin encolar, con instrucción de reintento" \
555
+ || { echo "FAIL front-integration offline (rc=$rc n=$n err=$err)"; fail=1; }
471
556
  rm -f "$PROJ/.claude/state/outbox/"*.json
472
557
  runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
473
558
 
@@ -493,6 +578,9 @@ rc=0; bash "$SO" gate inventado pass >/dev/null 2>&1 || rc=$?
493
578
  [ ! -s "$TMP/calls-ga.jsonl" ] && echo "OK gate desconocido no toca la red" || { echo "FAIL gate desconocido llamó al runtime"; fail=1; }
494
579
 
495
580
  # Gate feliz con evidencia por fichero (texto largo, tildes y comillas).
581
+ # [#44] El contrato del hub (VerdictIn y el catálogo GateVerdict) llama `verdict` a la
582
+ # clave y el reducer solo promueve con "PASS"/"FAIL" en MAYÚSCULA (domain.py:423-431):
583
+ # `status:"pass"` era rechazo garantizado en el sync Y en la cola.
496
584
  python3 -c "open('$TMP/ev.txt','w').write('suite verde: 42/42 «ñ» \"ok\"\n'*50)"
497
585
  rc=0; out="$(bash "$SO" gate tdd pass --evidence-file "$TMP/ev.txt")" || rc=$?
498
586
  [ "$rc" = 0 ] && echo "OK gate pass -> rc 0" || { echo "FAIL gate pass rc ($rc)"; fail=1; }
@@ -503,10 +591,20 @@ for line in open(sys.argv[1]):
503
591
  c=json.loads(line)
504
592
  if c["path"]=="/slices/sl-7/verdicts":
505
593
  b=json.loads(c["body"])
506
- ok = b["gate"]=="tdd" and b["status"]=="pass" and "42/42" in b["evidence"]
594
+ ok = b["gate"]=="tdd" and b.get("verdict")=="PASS" and "status" not in b and "42/42" in b["evidence"]
507
595
  print("SI" if ok else "NO")
508
596
  PYX
509
- )" = SI ] && echo "OK el veredicto viaja con gate/status/evidence" || { echo "FAIL cuerpo del veredicto"; fail=1; }
597
+ )" = SI ] && echo "OK el veredicto viaja con gate/verdict(PASS)/evidence" || { echo "FAIL cuerpo del veredicto"; fail=1; }
598
+
599
+ # `na` no existe en el catálogo del hub (cualquier verdict ≠ PASS/FAIL es bloqueo,
600
+ # invariante 5): en runtime no viaja ni se encola — aviso local y rc 0.
601
+ : > "$TMP/calls-ga.jsonl"
602
+ rm -f "$PROJ/.claude/state/outbox/"*.json
603
+ rc=0; out="$(bash "$SO" gate fidelity na 2>&1)" || rc=$?
604
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
605
+ [ "$rc" = 0 ] && [ "$n" = 0 ] && [ ! -s "$TMP/calls-ga.jsonl" ] && echo "$out" | grep -qi "na" \
606
+ && echo "OK gate na en runtime -> aviso local, sin red y sin encolar (el hub no lo admite)" \
607
+ || { echo "FAIL gate na (rc=$rc n=$n out=$out)"; fail=1; }
510
608
 
511
609
  # 422: compuerta mecánica del servidor -> rc 6, sin reintento y sin encolar.
512
610
  python3 - "$TMP/routes-ga.json" <<'PYX'
@@ -522,12 +620,15 @@ echo "$err" | grep -q "wiring_verified" && echo "OK gate 422 muestra la razón d
522
620
  n="$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')"
523
621
  [ "$n" = 0 ] && echo "OK gate 422 no encola (no se reintenta lo rechazado)" || { echo "FAIL gate 422 encoló ($n)"; fail=1; }
524
622
 
525
- # Offline: el veredicto se encola como verdict_reported (protegido) y rc 5.
623
+ # Offline: el veredicto se encola como gate_verdict (nombre del catálogo v2, #44) y rc 5.
526
624
  runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
527
625
  rc=0; bash "$SO" gate journey_smoke pass --evidence "smoke verde" >/dev/null 2>&1 || rc=$?
528
626
  [ "$rc" = 5 ] && echo "OK gate offline -> rc 5" || { echo "FAIL gate offline rc ($rc)"; fail=1; }
529
- grep -lq '"type": "verdict_reported"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
530
- && echo "OK gate offline encola verdict_reported" || { echo "FAIL gate offline no encoló"; fail=1; }
627
+ f="$(grep -l '"type": "gate_verdict"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
628
+ [ -n "$f" ] && [ "$(python3 -c "
629
+ import json;p=json.load(open('$f'))['payload']
630
+ print('SI' if p.get('verdict')=='PASS' and 'status' not in p else 'NO')")" = SI ] \
631
+ && echo "OK gate offline encola gate_verdict con verdict PASS" || { echo "FAIL gate offline no encoló gate_verdict"; fail=1; }
531
632
  rm -f "$PROJ/.claude/state/outbox/"*.json
532
633
 
533
634
  # Regresión: revisión final de rama — _ops_post_or_queue (offline, la rama que gate/submit/
@@ -554,12 +655,32 @@ rc=0; bash "$SO" submit --pr-url "https://example.invalid/pr/1" --openspec-chang
554
655
  [ "$rc" = 0 ] && echo "OK submit -> rc 0" || { echo "FAIL submit rc ($rc)"; fail=1; }
555
656
  grep -q '"path": "/slices/sl-7/submit"' "$TMP/calls-ga.jsonl" && echo "OK submit llama a su endpoint" || { echo "FAIL submit endpoint"; fail=1; }
556
657
 
658
+ # submit offline: se encola como slice_submitted con payload VACÍO [#44] — el catálogo no
659
+ # admite ningún campo (SliceSubmitted, extra="forbid"); pr_url/openspec_change vivían solo
660
+ # para el 422 del hub.
661
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
662
+ rm -f "$PROJ/.claude/state/outbox/"*.json
663
+ rc=0; bash "$SO" submit --pr-url "https://example.invalid/pr/1" --openspec-change add-cobros >/dev/null 2>&1 || rc=$?
664
+ f="$(grep -l '"type": "slice_submitted"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
665
+ [ "$rc" = 5 ] && [ -n "$f" ] && [ "$(python3 -c "
666
+ import json;d=json.load(open('$f'))
667
+ print('SI' if d['payload']=={} and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
668
+ && echo "OK submit offline encola slice_submitted con payload {} y slice_id" \
669
+ || { echo "FAIL submit offline (rc=$rc)"; fail=1; }
670
+ rm -f "$PROJ/.claude/state/outbox/"*.json
671
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
672
+
557
673
  # archive: SIEMPRE por la cola (opsx:archive corre dentro del PR) y protegido de la cota.
674
+ # [#44] SliceArchived no admite ningún campo (extra="forbid"): payload VACÍO, slice_id
675
+ # de primer nivel — con --epic/--pr-url el hub rechazaba TODO archivado offline.
558
676
  rm -f "$PROJ/.claude/state/outbox/"*.json
559
677
  rc=0; bash "$SO" archive --epic EP-003 --openspec-change add-cobros --pr-url "https://example.invalid/pr/1" >/dev/null || rc=$?
560
678
  [ "$rc" = 0 ] && echo "OK archive -> rc 0" || { echo "FAIL archive rc ($rc)"; fail=1; }
561
- grep -lq '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
562
- && echo "OK archive encola slice_archived" || { echo "FAIL archive no encoló"; fail=1; }
679
+ f="$(grep -l '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
680
+ [ -n "$f" ] && [ "$(python3 -c "
681
+ import json;d=json.load(open('$f'))
682
+ print('SI' if d['payload']=={} and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
683
+ && echo "OK archive encola slice_archived con payload {} y slice_id" || { echo "FAIL archive no encoló el formato del catálogo"; fail=1; }
563
684
  rm -f "$PROJ/.claude/state/outbox/"*.json
564
685
 
565
686
  # Regresión: revisión final de rama — si ops_enqueue_from_file falla, archive NUNCA debe
@@ -587,7 +708,9 @@ for line in open(sys.argv[1]):
587
708
  c=json.loads(line)
588
709
  if c["path"]=="/projects/p-1/mirror/transitions":
589
710
  b=json.loads(c["body"])
590
- ok = b["kind"]=="verdict_reported" and b["origin"]=="mirror" and b["payload"]["slice_ref"]["epic_code"]=="EP-009"
711
+ # [#44] el espejo también usa el nombre del catálogo (TIPOS_DE_ESPEJO del hub
712
+ # enumera gate_verdict, no verdict_reported).
713
+ ok = b["kind"]=="gate_verdict" and b["origin"]=="mirror" and b["payload"]["slice_ref"]["epic_code"]=="EP-009"
591
714
  print("SI" if ok else "NO")
592
715
  PYX
593
716
  )" = SI ] && echo "OK dual espeja con la identidad fichero-primaria" || { echo "FAIL espejo dual"; fail=1; }
@@ -606,7 +729,8 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
606
729
  # fichero es primario) — igual que el resto de cmd_* en dual (ver el bloque de arriba).
607
730
  DC="$ROOT/hooks/build/dual-compare.sh"
608
731
  mirror_escalated() {
609
- # SI/NO: ¿calls-ga.jsonl tiene un POST a mirror/transitions con kind=escalation_raised?
732
+ # SI/NO: ¿calls-ga.jsonl tiene un POST a mirror/transitions con kind=slice_escalated?
733
+ # [#44] la escalada usa el nombre del catálogo v2 (slice_escalated) también en el espejo.
610
734
  python3 - "$TMP/calls-ga.jsonl" <<'PYX'
611
735
  import json,sys
612
736
  found=False
@@ -617,7 +741,7 @@ for line in open(sys.argv[1]):
617
741
  continue
618
742
  if c.get("path")=="/projects/p-1/mirror/transitions":
619
743
  b=json.loads(c["body"])
620
- if b.get("kind")=="escalation_raised":
744
+ if b.get("kind")=="slice_escalated":
621
745
  found=True
622
746
  print("SI" if found else "NO")
623
747
  PYX
@@ -728,8 +852,24 @@ f="$(grep -l '"type": "wiring_checklist_seeded"' "$PROJ/.claude/state/outbox/"*.
728
852
  && echo "OK wiring seed encola los 300 items en un evento" || { echo "FAIL wiring seed payload"; fail=1; }
729
853
  [ "$(python3 -c "import json;print(json.load(open('$f')).get('slice_id'))")" = "sl-7" ] \
730
854
  && echo "OK wiring seed lleva slice_id" || { echo "FAIL wiring seed sin slice_id"; fail=1; }
855
+ # [#44] WiringChecklistItem del catálogo es {item_id, kind, ref} EXACTO (extra="forbid"):
856
+ # `status` no viaja (cada ítem nace failing en el fold del hub) y kind/ref nunca son null.
857
+ [ "$(python3 -c "
858
+ import json
859
+ items=json.load(open('$f'))['payload']['items']
860
+ ok = all(sorted(i.keys())==['item_id','kind','ref'] and i['kind'] is not None and i['ref'] is not None for i in items)
861
+ print('SI' if ok else 'NO')")" = SI ] \
862
+ && echo "OK wiring seed emite items {item_id,kind,ref} sin status ni nulls (catálogo v2)" \
863
+ || { echo "FAIL wiring seed items fuera del schema del catálogo"; fail=1; }
731
864
  rm -f "$PROJ/.claude/state/outbox/"*.json
732
865
 
866
+ # Checklist sin items válidos: rechazo LOCAL (el hub exige min_length=1), sin encolar.
867
+ echo '[]' > "$TMP/items-vacio.json"
868
+ rc=0; bash "$SO" wiring seed --file "$TMP/items-vacio.json" >/dev/null 2>&1 || rc=$?
869
+ [ "$rc" = 6 ] && [ "$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')" = 0 ] \
870
+ && echo "OK wiring seed vacío -> rc 6 sin encolar (el hub rechaza items vacíos)" \
871
+ || { echo "FAIL wiring seed vacío (rc=$rc)"; fail=1; }
872
+
733
873
  # Regresión: revisión final de rama — outbox no escribible -> wiring seed no reporta éxito.
734
874
  python3 -c "import json;json.dump([{'item_id':'w0','kind':'ac','ref':'HU-01'}], open('$TMP/items-small.json','w'))"
735
875
  chmod 555 "$PROJ/.claude/state/outbox"
@@ -767,11 +907,17 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
767
907
  rm -f "$PROJ/.claude/state/outbox/"*.json
768
908
 
769
909
  # progress: nota por argumento y por fichero; tipo EVICTABLE (no protegido).
910
+ # [#44] El catálogo v2 lo llama `progress_noted` (payload {note}, sin cambios de claves).
770
911
  rc=0; bash "$SO" progress "cableado el paso 2 del journey" >/dev/null || rc=$?
771
912
  [ "$rc" = 0 ] && echo "OK progress -> rc 0" || { echo "FAIL progress rc ($rc)"; fail=1; }
913
+ f="$(grep -l '"type": "progress_noted"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
914
+ [ -n "$f" ] && [ "$(python3 -c "
915
+ import json;d=json.load(open('$f'))
916
+ print('SI' if set(d['payload'].keys())=={'note'} and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
917
+ && echo "OK progress encola progress_noted {note} con slice_id" || { echo "FAIL progress no encoló progress_noted"; fail=1; }
772
918
  case " $RUNTIME_OUTBOX_PROTECTED " in
773
- *" progress_note_recorded "*) echo "FAIL progress_note_recorded no debe estar protegido"; fail=1 ;;
774
- *) echo "OK progress_note_recorded es evictable" ;;
919
+ *" progress_noted "*) echo "FAIL progress_noted no debe estar protegido"; fail=1 ;;
920
+ *) echo "OK progress_noted es evictable" ;;
775
921
  esac
776
922
  rm -f "$PROJ/.claude/state/outbox/"*.json
777
923
 
@@ -786,6 +932,8 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
786
932
  rm -f "$PROJ/.claude/state/outbox/"*.json
787
933
 
788
934
  # checkpoint: POST con rama y commit reales del repo del PROYECTO.
935
+ # [#44] La nota viaja como `summary` (CheckpointIn del hub; `note` se descartaba en
936
+ # silencio en el sync y era rechazo por extra="forbid" en la cola).
789
937
  : > "$TMP/calls-ga.jsonl"
790
938
  rc=0; bash "$SO" checkpoint --note "parada por contexto crítico" >/dev/null || rc=$?
791
939
  [ "$rc" = 0 ] && echo "OK checkpoint -> rc 0" || { echo "FAIL checkpoint rc ($rc)"; fail=1; }
@@ -796,30 +944,387 @@ for line in open(sys.argv[1]):
796
944
  c=json.loads(line)
797
945
  if c["path"]=="/checkpoints":
798
946
  b=json.loads(c["body"])
799
- ok = b.get("slice_id")=="sl-7" and "note" in b and "branch" in b and "commit_sha" in b
947
+ ok = b.get("slice_id")=="sl-7" and "summary" in b and "note" not in b and "branch" in b and "commit_sha" in b
800
948
  print("SI" if ok else "NO")
801
949
  PYX
802
- )" = SI ] && echo "OK el checkpoint viaja con slice/branch/commit/note" || { echo "FAIL cuerpo del checkpoint"; fail=1; }
950
+ )" = SI ] && echo "OK el checkpoint viaja con slice/branch/commit/summary" || { echo "FAIL cuerpo del checkpoint"; fail=1; }
803
951
 
804
- # checkpoint offline: se encola como checkpoint_created (protegido).
952
+ # checkpoint offline: se encola como checkpoint_recorded (nombre del catálogo, protegido),
953
+ # con el payload EXACTO del catálogo {branch, commit_sha, summary} — el slice_id va de
954
+ # primer nivel en el evento, jamás dentro del payload (extra="forbid").
805
955
  runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
806
956
  rm -f "$PROJ/.claude/state/outbox/"*.json
807
957
  rc=0; bash "$SO" checkpoint --note "sin red" >/dev/null 2>&1 || rc=$?
808
958
  [ "$rc" = 5 ] && echo "OK checkpoint offline -> rc 5" || { echo "FAIL checkpoint offline rc ($rc)"; fail=1; }
809
- grep -lq '"type": "checkpoint_created"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
810
- && echo "OK checkpoint offline encola" || { echo "FAIL checkpoint offline no encoló"; fail=1; }
959
+ f="$(grep -l '"type": "checkpoint_recorded"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
960
+ [ -n "$f" ] && [ "$(python3 -c "
961
+ import json;d=json.load(open('$f'))
962
+ ok = set(d['payload'].keys())<=set(['branch','commit_sha','summary']) and 'slice_id' not in d['payload'] and d.get('slice_id')=='sl-7'
963
+ print('SI' if ok else 'NO')")" = SI ] \
964
+ && echo "OK checkpoint offline encola checkpoint_recorded {branch,commit_sha,summary}" \
965
+ || { echo "FAIL checkpoint offline no encoló el formato del catálogo"; fail=1; }
966
+ rm -f "$PROJ/.claude/state/outbox/"*.json
967
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
968
+
969
+ # ══════════ runtime_dispatch_outbox: sobre {"events":…}, event_type y 4xx [#37] ══════════
970
+ # El hub exige EventBatchIn {"events":[EventIn]} y EventIn.event_type: el lote pelado con
971
+ # la clave "type" era 422 en CADA intento y la cola no drenaba jamás (reintento eterno,
972
+ # el usuario creía que reportó y el hub nunca recibió nada).
973
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
974
+ rm -rf "$PROJ/.claude/state/outbox/rejected"
975
+ runtime_enqueue_event slice_archived '{"epic_code":"EP-003","openspec_change":"add-cobros","pr_url":""}' sl-7
976
+ # Compat: fichero YA encolado por un consumidor con la 0.10.0 (clave interna "type").
977
+ cat > "$PROJ/.claude/state/outbox/00000000000000000000-compat.json" <<'JSON'
978
+ {"client_event_id": "cid-compat-1", "type": "telemetry_gap",
979
+ "payload": {"dropped": 1, "window_h": 1.0, "reason": "compat"},
980
+ "enqueued_at": "2026-08-27T00:00:00.000000Z"}
981
+ JSON
982
+ cid_new="$(python3 -c "
983
+ import json,glob
984
+ fs=[p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if 'compat' not in p and not p.endswith('.dispatch-state.json')]
985
+ print(json.load(open(fs[0]))['client_event_id'])")"
986
+ python3 - "$TMP/routes-ga.json" "$cid_new" <<'PYX'
987
+ import json,sys
988
+ p=sys.argv[1]; d=json.load(open(p))
989
+ # Ack con la forma REAL del hub (task_router.post_events): accepted/duplicates/rejected.
990
+ d["POST /events"]={"status":200,"body":{"accepted":[sys.argv[2]],"duplicates":["cid-compat-1"],"rejected":[]}}
991
+ json.dump(d,open(p,"w"))
992
+ PYX
993
+ : > "$TMP/calls-ga.jsonl"
994
+ rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
995
+ [ "$rc" = 0 ] && echo "OK dispatch 200 -> rc 0" || { echo "FAIL dispatch 200 rc ($rc)"; fail=1; }
996
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
997
+ import json,sys
998
+ ok=False
999
+ for line in open(sys.argv[1]):
1000
+ c=json.loads(line)
1001
+ if c["path"]=="/events":
1002
+ b=json.loads(c["body"])
1003
+ evs=b.get("events") if isinstance(b,dict) else None
1004
+ # [#44] slice_archived encolado por un consumidor 0.10.x con {epic_code,…}: la capa
1005
+ # de compat del despacho lo vacía (el catálogo no admite ningún campo).
1006
+ ok = isinstance(evs,list) and len(evs)==2 \
1007
+ and all("event_type" in e and "type" not in e for e in evs) \
1008
+ and {e["event_type"] for e in evs}=={"slice_archived","telemetry_gap"} \
1009
+ and any(e.get("slice_id")=="sl-7" for e in evs) \
1010
+ and all(e["payload"]=={} for e in evs if e["event_type"]=="slice_archived")
1011
+ print("SI" if ok else "NO")
1012
+ PYX
1013
+ )" = SI ] && echo "OK el lote viaja como {\"events\":[…]} con event_type y payloads del catálogo (compat 0.10.x)" \
1014
+ || { echo "FAIL sobre/clave del lote"; fail=1; }
1015
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
1016
+ [ "$n" = 0 ] && echo "OK la cola drena con el ack real del hub (accepted+duplicates)" || { echo "FAIL la cola no drenó ($n)"; fail=1; }
1017
+
1018
+ # Elemento rechazado DENTRO de un 200 (fuera de catálogo/payload inválido): se aparta a
1019
+ # rejected/ con su razón — reenviarlo es inútil; el resto del protocolo sigue fail-open.
1020
+ runtime_enqueue_event verdict_reported '{"gate":"tdd","status":"pass"}' sl-7
1021
+ cid_bad="$(python3 -c "
1022
+ import json,glob
1023
+ fs=[p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json')]
1024
+ print(json.load(open(fs[0]))['client_event_id'])")"
1025
+ python3 - "$TMP/routes-ga.json" "$cid_bad" <<'PYX'
1026
+ import json,sys
1027
+ p=sys.argv[1]; d=json.load(open(p))
1028
+ d["POST /events"]={"status":200,"body":{"accepted":[],"duplicates":[],
1029
+ "rejected":[{"client_event_id":sys.argv[2],"reason":"event_type desconocido para el catálogo v2"}]}}
1030
+ json.dump(d,open(p,"w"))
1031
+ PYX
1032
+ : > "$TMP/calls-ga.jsonl"
1033
+ rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
1034
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
1035
+ nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
1036
+ [ "$n" = 0 ] && [ "$nr" = 1 ] \
1037
+ && echo "OK el elemento rechazado por el hub se aparta a rejected/ (no se reenvía)" \
1038
+ || { echo "FAIL elemento rechazado (pendientes=$n rechazados=$nr)"; fail=1; }
1039
+ # [#44] El fichero 0.10.x `verdict_reported {gate, status}` viajó TRADUCIDO al catálogo v2
1040
+ # (gate_verdict {gate, verdict: PASS}) — la traducción vive en el único punto de salida.
1041
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
1042
+ import json,sys
1043
+ ok=False
1044
+ for line in open(sys.argv[1]):
1045
+ c=json.loads(line)
1046
+ if c["path"]=="/events":
1047
+ evs=json.loads(c["body"]).get("events") or []
1048
+ ok = len(evs)==1 and evs[0]["event_type"]=="gate_verdict" \
1049
+ and evs[0]["payload"].get("verdict")=="PASS" and "status" not in evs[0]["payload"]
1050
+ print("SI" if ok else "NO")
1051
+ PYX
1052
+ )" = SI ] && echo "OK verdict_reported 0.10.x drena traducido a gate_verdict {verdict: PASS}" \
1053
+ || { echo "FAIL traducción de verdict_reported en el despacho"; fail=1; }
1054
+ rm -rf "$PROJ/.claude/state/outbox/rejected"
1055
+
1056
+ # [#44] Renombres de compat 0.10.x en el despacho: checkpoint_created, escalation_raised y
1057
+ # progress_note_recorded drenan con el nombre Y el payload del catálogo v2 sin migración
1058
+ # (note→summary + slice_id fuera del payload; reason/gate/phase→cause; note se conserva).
1059
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
1060
+ runtime_enqueue_event checkpoint_created '{"slice_id":"sl-7","branch":"feature/x","commit_sha":"abc123","note":"parada"}'
1061
+ runtime_enqueue_event escalation_raised '{"reason":"bloqueo del AC-2","gate":"tdd","phase":"red"}' sl-7
1062
+ runtime_enqueue_event progress_note_recorded '{"note":"avance"}' sl-7
1063
+ cids_all="$(python3 -c "
1064
+ import json,glob
1065
+ fs=[p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json')]
1066
+ print(json.dumps([json.load(open(p))['client_event_id'] for p in fs]))")"
1067
+ python3 - "$TMP/routes-ga.json" "$cids_all" <<'PYX'
1068
+ import json,sys
1069
+ p=sys.argv[1]; d=json.load(open(p))
1070
+ d["POST /events"]={"status":200,"body":{"accepted":json.loads(sys.argv[2]),"duplicates":[],"rejected":[]}}
1071
+ json.dump(d,open(p,"w"))
1072
+ PYX
1073
+ : > "$TMP/calls-ga.jsonl"
1074
+ rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
1075
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
1076
+ import json,sys
1077
+ ok=False
1078
+ for line in open(sys.argv[1]):
1079
+ c=json.loads(line)
1080
+ if c["path"]=="/events":
1081
+ evs={e["event_type"]:e for e in json.loads(c["body"]).get("events") or []}
1082
+ cp=evs.get("checkpoint_recorded"); es=evs.get("slice_escalated"); pn=evs.get("progress_noted")
1083
+ ok = cp is not None and es is not None and pn is not None \
1084
+ and cp["payload"]=={"branch":"feature/x","commit_sha":"abc123","summary":"parada"} \
1085
+ and cp.get("slice_id")=="sl-7" \
1086
+ and set(es["payload"].keys())=={"cause"} and "bloqueo del AC-2" in es["payload"]["cause"] \
1087
+ and "tdd" in es["payload"]["cause"] \
1088
+ and pn["payload"]=={"note":"avance"}
1089
+ print("SI" if ok else "NO")
1090
+ PYX
1091
+ )" = SI ] && echo "OK los 4 renombres 0.10.x drenan con nombre y payload del catálogo v2" \
1092
+ || { echo "FAIL renombres de compat en el despacho"; fail=1; }
1093
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
1094
+ [ "$n" = 0 ] && echo "OK la cola 0.10.x drena entera tras la traducción" || { echo "FAIL cola 0.10.x no drenó ($n)"; fail=1; }
1095
+
1096
+ # [#44] Tipos SIN equivalente en el catálogo (tool_use_recorded, branch_drift,
1097
+ # front_integration_reported): el despacho los aparta LOCALMENTE a rejected/ sin gastar
1098
+ # red — mandarlos era rechazo garantizado. Si solo había de esos, no hay POST.
1099
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
1100
+ runtime_enqueue_event tool_use_recorded '{"tool":"Edit","path":"src/a.ts"}' sl-7
1101
+ runtime_enqueue_event branch_drift '{"real":"main","expected":"feature/x"}' sl-7
1102
+ runtime_enqueue_event front_integration_reported '{"merge_status":"merged"}'
1103
+ : > "$TMP/calls-ga.jsonl"
1104
+ rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
1105
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
1106
+ nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
1107
+ [ "$rc" = 0 ] && [ "$n" = 0 ] && [ "$nr" = 3 ] && ! grep -q '"path": "/events"' "$TMP/calls-ga.jsonl" \
1108
+ && echo "OK los tipos sin equivalente se apartan localmente a rejected/ sin tocar la red" \
1109
+ || { echo "FAIL pre-filtro de tipos sin equivalente (rc=$rc pendientes=$n rechazados=$nr)"; fail=1; }
1110
+ rm -rf "$PROJ/.claude/state/outbox/rejected"
1111
+
1112
+ # [#44] telemetry_gap de la evicción con el payload del catálogo: {dropped, window_h,
1113
+ # reason} (TelemetryGap: dropped>=1, window_h>0, reason<=2000) — el {dropped_count,
1114
+ # dropped_types} de 0.10.x era rechazo garantizado.
1115
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
1116
+ old_ev="$PROJ/.claude/state/outbox/00000000000000000001-viejo.json"
1117
+ python3 -c "import json;json.dump({'client_event_id':'ev-viejo-1','type':'progress_noted','payload':{'note':'x'},'enqueued_at':'2020-01-01T00:00:00Z'},open('$old_ev','w'))"
1118
+ touch -t 202001010000 "$old_ev"
1119
+ runtime_outbox_enforce_cap >/dev/null
1120
+ f="$(grep -l '"type": "telemetry_gap"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
1121
+ [ -n "$f" ] && [ "$(python3 -c "
1122
+ import json;p=json.load(open('$f'))['payload']
1123
+ ok = sorted(p.keys())==['dropped','reason','window_h'] and p['dropped']>=1 and p['window_h']==72.0 \
1124
+ and isinstance(p['reason'],str) and 'progress_noted' in p['reason'] and len(p['reason'])<=2000
1125
+ print('SI' if ok else 'NO')")" = SI ] \
1126
+ && echo "OK la evicción emite telemetry_gap {dropped, window_h, reason} del catálogo v2" \
1127
+ || { echo "FAIL payload del telemetry_gap de evicción"; fail=1; }
1128
+ # Coalescer: una segunda evicción actualiza el MISMO gap acumulando dropped.
1129
+ old_ev2="$PROJ/.claude/state/outbox/00000000000000000002-viejo.json"
1130
+ python3 -c "import json;json.dump({'client_event_id':'ev-viejo-2','type':'tool_use_recorded','payload':{},'enqueued_at':'2020-01-01T00:00:00Z'},open('$old_ev2','w'))"
1131
+ touch -t 202001010000 "$old_ev2"
1132
+ runtime_outbox_enforce_cap >/dev/null
1133
+ ngaps="$(grep -l '"type": "telemetry_gap"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | wc -l | tr -d ' ')"
1134
+ [ "$ngaps" = 1 ] && [ "$(python3 -c "
1135
+ import json;p=json.load(open('$f'))['payload']
1136
+ print('SI' if p['dropped']>=2 and sorted(p.keys())==['dropped','reason','window_h'] else 'NO')")" = SI ] \
1137
+ && echo "OK el coalescer acumula en un único telemetry_gap con el payload del catálogo" \
1138
+ || { echo "FAIL coalescer del telemetry_gap"; fail=1; }
811
1139
  rm -f "$PROJ/.claude/state/outbox/"*.json
1140
+
1141
+ # 4xx del LOTE (p.ej. 422 por contrato roto): NO reintentable — se loggea el cuerpo, el
1142
+ # lote se mueve a rejected/ y se avisa; nada de reintento eterno indistinguible de red caída.
1143
+ runtime_enqueue_event slice_archived '{"epic_code":"EP-004","openspec_change":"","pr_url":""}'
1144
+ python3 - "$TMP/routes-ga.json" <<'PYX'
1145
+ import json,sys
1146
+ p=sys.argv[1]; d=json.load(open(p))
1147
+ d["POST /events"]={"status":422,"body":{"detail":[{"type":"dict_type","loc":["body","events"]}]}}
1148
+ json.dump(d,open(p,"w"))
1149
+ PYX
1150
+ rm -f "$PROJ/.claude/state/outbox/.dispatch-state.json"
1151
+ rc=0; err="$(runtime_dispatch_outbox 2>&1 >/dev/null)" || rc=$?
1152
+ [ "$rc" != 0 ] && echo "OK dispatch 4xx -> rc != 0" || { echo "FAIL dispatch 4xx rc ($rc)"; fail=1; }
1153
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
1154
+ nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
1155
+ [ "$n" = 0 ] && [ "$nr" = 1 ] && echo "OK el lote 4xx se mueve a rejected/ y no se reintenta" \
1156
+ || { echo "FAIL lote 4xx (pendientes=$n rechazados=$nr)"; fail=1; }
1157
+ echo "$err" | grep -q "dict_type" && echo "OK el 4xx loggea el cuerpo del error del hub" \
1158
+ || { echo "FAIL 4xx sin cuerpo del error ($err)"; fail=1; }
1159
+ [ ! -f "$PROJ/.claude/state/outbox/.dispatch-state.json" ] \
1160
+ && echo "OK el 4xx no agenda backoff (no hay nada que reintentar)" || { echo "FAIL 4xx agendó backoff"; fail=1; }
1161
+ : > "$TMP/calls-ga.jsonl"
1162
+ rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
1163
+ grep -q '"path": "/events"' "$TMP/calls-ga.jsonl" && { echo "FAIL dispatch reintentó un lote 4xx"; fail=1; } \
1164
+ || echo "OK tras el 4xx la cola queda vacía (sin reintento)"
1165
+ rm -rf "$PROJ/.claude/state/outbox/rejected"
1166
+
1167
+ # 5xx y 429 SIGUEN siendo fail-open: la cola se conserva y el backoff se agenda.
1168
+ runtime_enqueue_event slice_archived '{"epic_code":"EP-005","openspec_change":"","pr_url":""}'
1169
+ for st in 500 429; do
1170
+ python3 - "$TMP/routes-ga.json" "$st" <<'PYX'
1171
+ import json,sys
1172
+ p=sys.argv[1]; d=json.load(open(p))
1173
+ d["POST /events"]={"status":int(sys.argv[2]),"body":{"detail":"transitorio"}}
1174
+ json.dump(d,open(p,"w"))
1175
+ PYX
1176
+ rm -f "$PROJ/.claude/state/outbox/.dispatch-state.json"
1177
+ rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
1178
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
1179
+ [ "$rc" = 1 ] && [ "$n" = 1 ] && [ -f "$PROJ/.claude/state/outbox/.dispatch-state.json" ] \
1180
+ && echo "OK $st conserva la cola y agenda backoff (fail-open intacto)" \
1181
+ || { echo "FAIL $st (rc=$rc pendientes=$n)"; fail=1; }
1182
+ done
1183
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
1184
+
1185
+ # ══════════════ [#44] anti-drift: todo tipo encolable pertenece al catálogo v2 ══════════════
1186
+ # Catálogo de REFERENCIA del test: réplica literal de `event_catalog.py` del hub
1187
+ # (trycore-ia-hub, backend/app/modules/orchestrator/event_catalog.py, catálogo v2,
1188
+ # verificado el 2026-08-28). Claves admitidas por schema (extra="forbid" en el hub; el
1189
+ # campo `source` lo estampa el SERVIDOR en la ingesta espejo y el cliente jamás lo manda).
1190
+ # Ámbito: `slice` exige slice_id de primer nivel; `project` lo PROHÍBE
1191
+ # (progress_service._apply_and_append + TIPOS_POR_AGREGADO["project"]).
1192
+ # Si mañana un productor encola un tipo o una clave nueva sin pasar por aquí, este
1193
+ # bloque debe ponerse rojo: actualiza el catálogo de referencia SOLO tras verificar
1194
+ # el catálogo real del hub.
1195
+ cat > "$TMP/catalogo-v2.py" <<'PYCAT'
1196
+ CATALOGO = {
1197
+ "gate_verdict": {"claves": {"gate", "verdict", "evidence", "cause", "details_ref"},
1198
+ "obligatorias": {"gate", "verdict"}, "ambito": "slice"},
1199
+ "checkpoint_recorded": {"claves": {"branch", "commit_sha", "summary"},
1200
+ "obligatorias": {"branch", "commit_sha"}, "ambito": "slice"},
1201
+ "progress_noted": {"claves": {"note"}, "obligatorias": {"note"}, "ambito": "slice"},
1202
+ "handoff_recorded": {"claves": {"note", "resume_hint", "stopped_at"},
1203
+ "obligatorias": {"note"}, "ambito": "slice"},
1204
+ "slice_escalated": {"claves": {"cause"}, "obligatorias": {"cause"}, "ambito": "slice"},
1205
+ "slice_submitted": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
1206
+ "slice_archived": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
1207
+ "wiring_checklist_seeded": {"claves": {"items"}, "obligatorias": {"items"}, "ambito": "slice"},
1208
+ "wiring_item_updated": {"claves": {"item_id", "status", "evidence", "kind", "ref"},
1209
+ "obligatorias": {"item_id", "status"}, "ambito": "slice"},
1210
+ "project_fact_updated": {"claves": {"fact", "value", "source"},
1211
+ "obligatorias": {"fact", "source"}, "ambito": "project"},
1212
+ "telemetry_gap": {"claves": {"dropped", "window_h", "reason"},
1213
+ "obligatorias": {"dropped", "window_h", "reason"}, "ambito": "project"},
1214
+ }
1215
+ PYCAT
1216
+
1217
+ # (a) ESTÁTICO: los tipos literales de TODOS los call sites que encolan (hooks/build/**)
1218
+ # están en el catálogo de referencia — un productor nuevo con tipo fuera de catálogo
1219
+ # pone esto rojo aunque nadie le escriba caso dinámico.
1220
+ [ "$(python3 - "$TMP/catalogo-v2.py" "$ROOT/hooks/build" <<'PYX'
1221
+ import re,sys,glob,os
1222
+ ns={}
1223
+ exec(open(sys.argv[1]).read(), ns)
1224
+ catalogo=ns["CATALOGO"]
1225
+ pat=re.compile(r'(?:runtime_enqueue_event|ops_enqueue_from_file|_ops_emit_event)\s+"?([a-z][a-z_]+)"?')
1226
+ pat_pq=re.compile(r'"\$payload"\s+([a-z][a-z_]+)')
1227
+ encontrados=set()
1228
+ for path in glob.glob(os.path.join(sys.argv[2],"**","*.sh"), recursive=True):
1229
+ for line in open(path, encoding="utf-8"):
1230
+ if line.lstrip().startswith("#"):
1231
+ continue
1232
+ for m in pat.finditer(line):
1233
+ encontrados.add(m.group(1))
1234
+ if "_ops_post_or_queue" in line:
1235
+ m=pat_pq.search(line)
1236
+ if m:
1237
+ encontrados.add(m.group(1))
1238
+ fuera=sorted(t for t in encontrados if t not in catalogo)
1239
+ # El regex debe seguir viendo a los productores conocidos: si deja de encontrarlos,
1240
+ # el guard está ciego y también es un fallo.
1241
+ esperados={"gate_verdict","checkpoint_recorded","progress_noted","slice_escalated",
1242
+ "slice_submitted","slice_archived","wiring_checklist_seeded",
1243
+ "wiring_item_updated","project_fact_updated","handoff_recorded"}
1244
+ ciegos=sorted(esperados-encontrados)
1245
+ if fuera:
1246
+ print("FUERA_DE_CATALOGO:"+",".join(fuera))
1247
+ elif ciegos:
1248
+ print("REGEX_CIEGO:"+",".join(ciegos))
1249
+ else:
1250
+ print("SI")
1251
+ PYX
1252
+ )" = SI ] && echo "OK todos los tipos literales encolados por hooks/build pertenecen al catálogo v2" \
1253
+ || { echo "FAIL anti-drift estático (tipo fuera de catálogo o guard ciego)"; fail=1; }
1254
+
1255
+ # (b) DINÁMICO: se ejecutan TODOS los productores reales en runtime sin red y se valida
1256
+ # cada evento encolado (tipo en catálogo, claves ⊆ admitidas, obligatorias presentes,
1257
+ # slice_id según el ámbito, items del wiring con la forma exacta).
1258
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
1259
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
1260
+ bash "$SO" gate tdd pass --evidence "verde" >/dev/null 2>&1
1261
+ bash "$SO" submit --pr-url "https://example.invalid/pr/9" >/dev/null 2>&1
1262
+ bash "$SO" archive --epic EP-003 >/dev/null 2>&1
1263
+ bash "$SO" wiring seed --file "$TMP/items-small.json" >/dev/null 2>&1
1264
+ bash "$SO" wiring update w1 passing --kind e2e --ref HU-1 --evidence "x" >/dev/null 2>&1
1265
+ bash "$SO" progress "nota de avance" >/dev/null 2>&1
1266
+ bash "$SO" checkpoint --note "corte" >/dev/null 2>&1
1267
+ bash "$SO" escalate "bloqueo" --gate tdd >/dev/null 2>&1
1268
+ bash "$SO" fact harness-phase building >/dev/null 2>&1
1269
+ old_ev="$PROJ/.claude/state/outbox/00000000000000000003-viejo.json"
1270
+ python3 -c "import json;json.dump({'client_event_id':'ev-viejo-3','type':'progress_noted','payload':{'note':'x'},'enqueued_at':'2020-01-01T00:00:00Z'},open('$old_ev','w'))"
1271
+ touch -t 202001010000 "$old_ev"
1272
+ runtime_outbox_enforce_cap >/dev/null
1273
+ res_antidrift="$(python3 - "$TMP/catalogo-v2.py" "$PROJ/.claude/state/outbox" <<'PYX'
1274
+ import json,sys,glob,os
1275
+ ns={}
1276
+ exec(open(sys.argv[1]).read(), ns)
1277
+ catalogo=ns["CATALOGO"]
1278
+ errores=[]
1279
+ vistos=set()
1280
+ for path in sorted(glob.glob(os.path.join(sys.argv[2],"*.json"))):
1281
+ if os.path.basename(path)==".dispatch-state.json":
1282
+ continue
1283
+ d=json.load(open(path))
1284
+ t=d.get("type") or ""
1285
+ p=d.get("payload") or {}
1286
+ vistos.add(t)
1287
+ spec=catalogo.get(t)
1288
+ if spec is None:
1289
+ errores.append("tipo fuera de catálogo: %r" % t); continue
1290
+ extra=set(p)-spec["claves"]
1291
+ faltan=spec["obligatorias"]-set(p)
1292
+ if extra: errores.append("%s: claves fuera de schema %s" % (t, sorted(extra)))
1293
+ if faltan: errores.append("%s: faltan obligatorias %s" % (t, sorted(faltan)))
1294
+ if spec["ambito"]=="slice" and not d.get("slice_id"):
1295
+ errores.append("%s: ámbito slice sin slice_id de primer nivel" % t)
1296
+ if spec["ambito"]=="project" and d.get("slice_id"):
1297
+ errores.append("%s: ámbito proyecto con slice_id (el hub lo rechaza)" % t)
1298
+ if t=="wiring_checklist_seeded":
1299
+ items=p.get("items") or []
1300
+ if not items: errores.append("wiring_checklist_seeded: items vacío (min_length=1)")
1301
+ for it in items:
1302
+ if set(it)!={"item_id","kind","ref"} or any(it[k] is None for k in it):
1303
+ errores.append("wiring_checklist_seeded: item fuera de schema %r" % it); break
1304
+ esperados={"gate_verdict","slice_submitted","slice_archived","wiring_checklist_seeded",
1305
+ "wiring_item_updated","progress_noted","checkpoint_recorded","slice_escalated",
1306
+ "project_fact_updated","telemetry_gap"}
1307
+ faltantes=sorted(esperados-vistos)
1308
+ if faltantes:
1309
+ errores.append("productores sin evento encolado (¿cambió un subcomando?): %s" % faltantes)
1310
+ print("SI" if not errores else "NO: "+" | ".join(errores))
1311
+ PYX
1312
+ )"
1313
+ [ "$res_antidrift" = SI ] \
1314
+ && echo "OK todos los eventos encolados por los productores reales validan contra el catálogo v2" \
1315
+ || { echo "FAIL anti-drift dinámico -> $res_antidrift"; fail=1; }
1316
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
812
1317
  runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
813
1318
 
814
- # ══════════════ scripts/lib/graph-bundle.py [Task 13] ══════════════
1319
+ # ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
815
1320
  GB="$ROOT/scripts/lib/graph-bundle.py"
816
1321
  cat > "$TMP/epics.json" <<'JSON'
817
1322
  {"project_ref":"acme","epics":[
818
1323
  {"code":"EP-002","title":"Cobros","layer":"business","files_scope":["src/pagos/**"],"depends_on":["EP-001"],"stories":[{"id":"HU-011","title":"Cobrar"}]},
819
1324
  {"code":"EP-001","title":"Cimiento","layer":"foundational","files_scope":["src/core/**"],"depends_on":[]},
820
1325
  {"code":"EP-002","title":"Duplicada","layer":"business"},
821
- {"code":"EP-003","title":"Suelta","depends_on":["EP-404"]}
822
- ],"release_lines":[{"id":"R1-mvp","epics":["EP-001","EP-002"]}]}
1326
+ {"code":"EP-003","depends_on":["EP-404"]}
1327
+ ],"release_lines":[{"id":"R1-mvp","epics":["EP-001","EP-002"]},{"id":"R2","epics":["EP-002"]}]}
823
1328
  JSON
824
1329
  out="$(python3 "$GB" < "$TMP/epics.json" 2> "$TMP/gb.err")"
825
1330
  rc=$?
@@ -828,8 +1333,23 @@ rc=$?
828
1333
  && echo "OK graph-bundle deduplica y ordena determinísticamente" || { echo "FAIL orden/dedup del bundle"; fail=1; }
829
1334
  grep -q "EP-002" "$TMP/gb.err" && echo "OK graph-bundle avisa del código duplicado" || { echo "FAIL sin aviso de duplicado"; fail=1; }
830
1335
  grep -q "EP-404" "$TMP/gb.err" && echo "OK graph-bundle avisa de la dependencia inexistente" || { echo "FAIL sin aviso de dep inexistente"; fail=1; }
831
- [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["kind"],d["bundle_version"])')" = "graph 1" ] \
832
- && echo "OK el bundle se autodescribe (kind/bundle_version)" || { echo "FAIL cabecera del bundle"; fail=1; }
1336
+ [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["kind"],d["bundle_version"])')" = "graph 2" ] \
1337
+ && echo "OK el bundle se autodescribe (kind/bundle_version 2)" || { echo "FAIL cabecera del bundle"; fail=1; }
1338
+ # ── Formato del hub (issue #41): GraphService.import_graph rechaza TODO el grafo
1339
+ # si layer ∉ {FOUNDATIONAL, BUSINESS}; las historias viajan con `code` (el hub
1340
+ # hace story_in["code"]); la línea de release es un string POR ÉPICA.
1341
+ [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(",".join(str(e["layer"]) for e in d["epics"]))')" = "FOUNDATIONAL,BUSINESS,None" ] \
1342
+ && echo "OK layer en MAYÚSCULA (VALID_LAYERS del hub); ausente -> null" || { echo "FAIL layer no está en formato del hub"; fail=1; }
1343
+ [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);h=d["epics"][1]["stories"][0];print(h.get("code"),h.get("title"),"id" in h)')" = "HU-011 Cobrar False" ] \
1344
+ && echo "OK las historias llevan code+title (sin id)" || { echo "FAIL historias sin code del hub"; fail=1; }
1345
+ [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(",".join(str(e.get("release_line")) for e in d["epics"]));print("release_lines" in d)')" = "R1-mvp,R1-mvp,None
1346
+ False" ] \
1347
+ && echo "OK release_line string por épica (sin release_lines aparte)" || { echo "FAIL release_line por épica"; fail=1; }
1348
+ grep -q "R2" "$TMP/gb.err" && echo "OK avisa de la épica en dos líneas de release (gana la primera)" || { echo "FAIL sin aviso de doble línea"; fail=1; }
1349
+ [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["epics"][2]["title"])')" = "EP-003" ] \
1350
+ && grep -q "sin .title." "$TMP/gb.err" \
1351
+ && echo "OK title ausente cae al código con aviso (el hub lo exige)" || { echo "FAIL fallback de title"; fail=1; }
1352
+ grep -qi "layer" "$TMP/gb.err" && echo "OK avisa del layer ausente (el hub rechazará el grafo entero)" || { echo "FAIL sin aviso de layer"; fail=1; }
833
1353
  # El bundle es DETERMINISTA: dos corridas idénticas salvo el sello temporal.
834
1354
  out2="$(python3 "$GB" < "$TMP/epics.json" 2>/dev/null)"
835
1355
  a="$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);d.pop("generated_at");print(json.dumps(d,sort_keys=True))')"