@trycore/spec-build-harness 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/METODOLOGIA.md +6 -1
  3. package/README.md +1 -0
  4. package/VERSION +1 -1
  5. package/agents/build/dor-dod-gatekeeper.md +10 -3
  6. package/commands/build/epic.md +109 -0
  7. package/commands/build/onboard.md +59 -17
  8. package/commands/build/slice.md +4 -2
  9. package/commands/build/work.md +6 -2
  10. package/config/build-config.template.json +2 -1
  11. package/dist/commands/init.js +13 -0
  12. package/dist/commands/status.js +13 -1
  13. package/dist/lib/normalize.js +11 -0
  14. package/dist/lib/paths.js +1 -0
  15. package/dist/lib/runtime-client.js +20 -0
  16. package/dist/lib/state-bundle.js +15 -1
  17. package/docs/commands.md +2 -1
  18. package/docs/hooks.md +11 -1
  19. package/docs/runtime/protocolo-cliente-runtime.md +90 -0
  20. package/hooks/build/design-source-guard.sh +12 -1
  21. package/hooks/build/heartbeat.sh +64 -4
  22. package/hooks/build/lib/agent-context.sh +16 -7
  23. package/hooks/build/lib/config.sh +25 -1
  24. package/hooks/build/lib/runtime-client.sh +540 -9
  25. package/hooks/build/lib/runtime-ops.sh +108 -0
  26. package/hooks/build/scaffold-guard.sh +14 -1
  27. package/hooks/build/slice-ops.sh +498 -16
  28. package/package.json +1 -1
  29. package/scripts/lib/graph-bundle.py +165 -1
  30. package/scripts/tests/test-config.sh +41 -0
  31. package/scripts/tests/test-hooks-runtime.sh +198 -3
  32. package/scripts/tests/test-install.sh +23 -0
  33. package/scripts/tests/test-runtime-client.sh +500 -0
  34. package/scripts/tests/test-skill-ops.sh +574 -0
  35. package/skills/building-a-slice/references/dor.md +21 -9
  36. package/skills/building-a-slice/references/foundation-contract.md +4 -0
  37. package/state/README.md +12 -0
@@ -295,4 +295,504 @@ print('slice_id' in json.load(open(files[0])))
295
295
  kill "$STUB_PID4" 2>/dev/null; wait "$STUB_PID4" 2>/dev/null
296
296
  unset CLAUDE_PROJECT_DIR
297
297
 
298
+ # ── [#62] Carril directo de la outbox ────────────────────────────────────────────────
299
+ PORT_D="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
300
+ cat > "$TMP/routes-d.json" <<JSON
301
+ {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-77", "status": "PROPOSED"}},
302
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
303
+ JSON
304
+ : > "$TMP/calls-d.jsonl"
305
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_D" "$TMP/routes-d.json" "$TMP/calls-d.jsonl" &
306
+ STUB_D=$!
307
+ trap 'kill $STUB_D 2>/dev/null; rm -rf "$TMP"' EXIT
308
+ sleep 0.3
309
+
310
+ export CLAUDE_PROJECT_DIR="$TMP/projD"
311
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
312
+ BUILD_CONFIG_FILE="$TMP/projD-cfg.json"
313
+ echo '{"runtime":{"mode":"dual"}}' > "$BUILD_CONFIG_FILE"
314
+ export BUILD_CONFIG_FILE
315
+
316
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_D\",\"project_token\":\"tok-d\",\"agent_key\":\"ak-1\",\"project_id\":\"p-1\"}"
317
+ rm -rf "$CLAUDE_PROJECT_DIR/.claude/state/outbox"
318
+
319
+ echo '{"title":"Notificaciones","objective":"avisar al usuario"}' > "$TMP/prop.json"
320
+ CID="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
321
+ [ -n "$CID" ] && echo "OK runtime_enqueue_direct devuelve el client_event_id" \
322
+ || { echo "FAIL runtime_enqueue_direct sin id"; fail=1; }
323
+ f="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | head -1)"
324
+ python3 -c "
325
+ import json
326
+ d=json.load(open('$f'))
327
+ ok = d['channel']=='direct' and d['method']=='POST' and d['path']=='/projects/p-1/epic-proposals' \
328
+ and d['type']=='epic_proposed' and d['payload']['title']=='Notificaciones'
329
+ print('SI' if ok else 'NO')" | grep -q SI \
330
+ && echo "OK el fichero del carril directo lleva canal, metodo, ruta y payload" \
331
+ || { echo "FAIL formato del carril directo"; fail=1; }
332
+
333
+ # El lote de /events NO debe llevarse el fichero directo por delante.
334
+ # [Corrección mecánica: el stub relee routes-d.json en cada request (no solo al arrancar),
335
+ # así que se le inyecta el client_event_id del evento de lote recién encolado en la lista
336
+ # de aceptados — igual que hace el bloque de backoff más arriba (STUB_PID3) — para que el
337
+ # lote quede confirmado y la comprobación de "n=0" verifique lo que dice verificar: que
338
+ # AMBOS carriles quedan drenados, no que el carril directo sobrevive solo porque el lote
339
+ # nunca se confirma.
340
+ runtime_enqueue_event progress_noted '{"note":"hola"}' "sl-1"
341
+ evt_cid="$(python3 -c "
342
+ import json,glob
343
+ for p in glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json'):
344
+ d=json.load(open(p))
345
+ if d.get('type')=='progress_noted':
346
+ print(d['client_event_id']); break
347
+ ")"
348
+ cat > "$TMP/routes-d.json" <<JSON
349
+ {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-77", "status": "PROPOSED"}},
350
+ "POST /events": {"status": 200, "body": {"accepted": ["$evt_cid"], "duplicates": [], "rejected": []}}}
351
+ JSON
352
+ runtime_dispatch_outbox >/dev/null 2>&1
353
+ sent="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
354
+ import json,sys
355
+ paths=[json.loads(l)["path"] for l in open(sys.argv[1]) if l.strip()]
356
+ print("SI" if "/projects/p-1/epic-proposals" in paths and "/events" in paths else "NO")
357
+ PY
358
+ )"
359
+ [ "$sent" = SI ] && echo "OK el despacho atiende los dos carriles en el mismo ciclo" \
360
+ || { echo "FAIL un carril se comio al otro ($sent)"; fail=1; }
361
+ body="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
362
+ import json,sys
363
+ for l in open(sys.argv[1]):
364
+ d=json.loads(l)
365
+ if d["path"]=="/projects/p-1/epic-proposals":
366
+ print("SI" if json.loads(d["body"]).get("title")=="Notificaciones" else "NO")
367
+ break
368
+ PY
369
+ )"
370
+ [ "$body" = SI ] && echo "OK el carril directo manda el payload tal cual" \
371
+ || { echo "FAIL payload del carril directo ($body)"; fail=1; }
372
+ [ -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/acks/$CID.json" ] && echo "OK el ack del carril directo queda en acks/" \
373
+ || { echo "FAIL sin ack en acks/"; fail=1; }
374
+ pid="$(python3 -c "
375
+ import json
376
+ print(json.load(open('$CLAUDE_PROJECT_DIR/.claude/state/outbox/acks/$CID.json'))['response'].get('proposal_id'))" 2>/dev/null)"
377
+ [ "$pid" = "prop-77" ] && echo "OK el ack conserva la respuesta del hub" \
378
+ || { echo "FAIL el ack perdio la respuesta ($pid)"; fail=1; }
379
+ n="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
380
+ [ "$n" = 0 ] && echo "OK el carril directo borra el fichero entregado" \
381
+ || { echo "FAIL fichero directo sin borrar ($n)"; fail=1; }
382
+
383
+ # 404 = instancia del hub sin soporte: se aparta con una razón que lo NOMBRA, no se reintenta.
384
+ cat > "$TMP/routes-d.json" <<JSON
385
+ {"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
386
+ JSON
387
+ CID2="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
388
+ runtime_dispatch_outbox >/dev/null 2>&1
389
+ # [Ruling R8] la variante glob del brief expande a varios argumentos dentro de `-f` (bash
390
+ # inválido); solo la variante `find` es correcta.
391
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected" -name "*$CID2.json" 2>/dev/null | head -1)"
392
+ [ -n "$found" ] && echo "OK un 404 aparta la peticion directa" || { echo "FAIL 404 sin apartar"; fail=1; }
393
+ python3 -c "
394
+ import json
395
+ print(json.load(open('$found')).get('rejected_reason',''))" 2>/dev/null | grep -qi 'sin soporte' \
396
+ && echo "OK la razon del 404 nombra la falta de soporte del hub" \
397
+ || { echo "FAIL razon del 404 poco accionable"; fail=1; }
398
+
399
+ # Combo [Important 1, revisión ronda 1]: el carril directo falla TRANSITORIO (5xx) en el
400
+ # MISMO ciclo en que el lote de /events se despacha con éxito. El rc de la función no puede
401
+ # ocultar que algo del carril directo quedó pendiente solo porque el lote fue bien — es lo
402
+ # que consumen `scripts/tests/test-skill-ops.sh` (`|| rc=$?`).
403
+ runtime_enqueue_event progress_noted '{"note":"combo"}' "sl-3"
404
+ evt_cid2="$(python3 -c "
405
+ import json,glob
406
+ for p in glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json'):
407
+ d=json.load(open(p))
408
+ if d.get('type')=='progress_noted':
409
+ print(d['client_event_id']); break
410
+ ")"
411
+ CID5="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
412
+ cat > "$TMP/routes-d.json" <<JSON
413
+ {"POST /projects/p-1/epic-proposals": {"status": 500, "body": {"error": "boom"}},
414
+ "POST /events": {"status": 200, "body": {"accepted": ["$evt_cid2"], "duplicates": [], "rejected": []}}}
415
+ JSON
416
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
417
+ runtime_dispatch_outbox >/dev/null 2>&1
418
+ rc=$?
419
+ [ "$rc" -ne 0 ] && echo "OK rc distinto de 0 si el directo falla transitorio aunque el lote se despache bien" \
420
+ || { echo "FAIL rc 0 pese a directo pendiente ($rc)"; fail=1; }
421
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID5.json" | head -1)"
422
+ [ -n "$found" ] && echo "OK la peticion directa transitoria sigue en cola pese al exito del lote" \
423
+ || { echo "FAIL la peticion directa transitoria se perdio"; fail=1; }
424
+ gone="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$evt_cid2.json" | head -1)"
425
+ [ -z "$gone" ] && echo "OK el lote se entrego pese a que el carril directo fallo en el mismo ciclo" \
426
+ || { echo "FAIL el lote no se entrego"; fail=1; }
427
+
428
+ # Offline: la petición directa SOBREVIVE en la cola (no se aparta, no se pierde).
429
+ kill "$STUB_D" 2>/dev/null; wait "$STUB_D" 2>/dev/null
430
+ CID3="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
431
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
432
+ runtime_dispatch_outbox >/dev/null 2>&1
433
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID3.json" | head -1)"
434
+ [ -n "$found" ] && echo "OK offline la peticion directa sigue encolada" \
435
+ || { echo "FAIL la peticion directa se perdio offline"; fail=1; }
436
+
437
+ # La propuesta es protegida: la cota jamás la evicta.
438
+ case " $RUNTIME_OUTBOX_PROTECTED " in
439
+ *" epic_proposed "*) echo "OK epic_proposed es un tipo protegido de la cota" ;;
440
+ *) echo "FAIL epic_proposed evictable"; fail=1 ;;
441
+ esac
442
+
443
+ # [#63] La versión de grafo se estampa AL DESPACHAR: una propuesta encolada ayer no puede
444
+ # salir con la versión de ayer. El stub anterior está muerto (test offline lo mató); se
445
+ # levanta uno nuevo en el mismo puerto, reutilizando $CLAUDE_PROJECT_DIR=$TMP/projD (Ruling R2).
446
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_D" "$TMP/routes-d.json" "$TMP/calls-d.jsonl" &
447
+ STUB_D=$!
448
+ sleep 0.3
449
+ cat > "$TMP/routes-d.json" <<'JSON'
450
+ {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-88"}},
451
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
452
+ JSON
453
+ # El test offline anterior dejó "$CID3" pendiente en la cola sin despachar (el stub estaba
454
+ # muerto): se drena aquí para que no se cuele delante de "$CID4" y contamine el "primer
455
+ # match" de /projects/p-1/epic-proposals que verifican las aserciones de abajo.
456
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
457
+ runtime_dispatch_outbox >/dev/null 2>&1
458
+ : > "$TMP/calls-d.jsonl"
459
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":42}}'
460
+ CID4="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
461
+ # La versión cambia ENTRE el encolado y el despacho: gana la del despacho.
462
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":47}}'
463
+ : > "$TMP/calls-d.jsonl"
464
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
465
+ runtime_dispatch_outbox >/dev/null 2>&1
466
+ sent="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
467
+ import json,sys
468
+ for l in open(sys.argv[1]):
469
+ d=json.loads(l)
470
+ if d["path"]=="/projects/p-1/epic-proposals":
471
+ print(json.loads(d["body"]).get("graph_version"))
472
+ break
473
+ PY
474
+ )"
475
+ [ "$sent" = 47 ] && echo "OK la version se estampa al despachar, no al encolar" \
476
+ || { echo "FAIL version estampada al encolar ($sent)"; fail=1; }
477
+
478
+ # Sin el 5º argumento, el cuerpo NO lleva versión (retrocompatible).
479
+ CID5="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
480
+ : > "$TMP/calls-d.jsonl"
481
+ runtime_dispatch_outbox >/dev/null 2>&1
482
+ has="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
483
+ import json,sys
484
+ for l in open(sys.argv[1]):
485
+ d=json.loads(l)
486
+ if d["path"]=="/projects/p-1/epic-proposals":
487
+ print("SI" if "graph_version" in json.loads(d["body"]) else "NO")
488
+ break
489
+ PY
490
+ )"
491
+ [ "$has" = NO ] && echo "OK sin estampado pedido, el cuerpo no cambia" || { echo "FAIL estampado no pedido"; fail=1; }
492
+
493
+ # 409 stale_graph en el carril directo: se anota el desfase y la petición SIGUE en la cola
494
+ # (la refresca el heartbeat, #61), no se pierde ni se aparta a la primera.
495
+ cat > "$TMP/routes-d.json" <<'JSON'
496
+ {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 60}},
497
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
498
+ JSON
499
+ runtime_graph_clear_stale
500
+ CID6="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
501
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
502
+ runtime_dispatch_outbox >/dev/null 2>&1
503
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID6.json" | head -1)"
504
+ [ -n "$found" ] && echo "OK un 409 stale_graph conserva la peticion en la cola" \
505
+ || { echo "FAIL la peticion se perdio con el 409"; fail=1; }
506
+ runtime_graph_status_read | python3 -c "
507
+ import json,sys; print(json.load(sys.stdin).get('server'))" | grep -qx 60 \
508
+ && echo "OK el 409 del carril directo anota el desfase" || { echo "FAIL desfase sin anotar"; fail=1; }
509
+
510
+ # Tres 409 seguidos: se aparta con un mensaje que nombra el desfase (no bucle infinito).
511
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
512
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
513
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID6.json" | head -1)"
514
+ [ -z "$found" ] && echo "OK tras 3 rechazos la peticion deja de reintentarse" \
515
+ || { echo "FAIL reintento infinito del 409"; fail=1; }
516
+ rej="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected" -name "*$CID6.json" | head -1)"
517
+ python3 -c "
518
+ import json
519
+ print(json.load(open('$rej')).get('rejected_reason',''))" 2>/dev/null | grep -q '60' \
520
+ && echo "OK la razon del apartado nombra la version del hub" \
521
+ || { echo "FAIL razon del apartado sin desfase"; fail=1; }
522
+
523
+ # ── Ronda de arreglo 1: Important 1/3/4 y Minor 6 ─────────────────────────────────────
524
+
525
+ # [Important 1] Una versión de grafo NO ENTERA (hub#115 aún no existe; nada coacciona el
526
+ # tipo en agent-context.sh) no debe vaciar el payload y disfrazar el fallo de "rechazo del
527
+ # hub": la propuesta original tiene que seguir viajando intacta.
528
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":"42.5"}}'
529
+ cat > "$TMP/routes-d.json" <<'JSON'
530
+ {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-100"}},
531
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
532
+ JSON
533
+ CID9="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
534
+ : > "$TMP/calls-d.jsonl"
535
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
536
+ runtime_dispatch_outbox >/dev/null 2>&1
537
+ body_ok="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
538
+ import json,sys
539
+ for l in open(sys.argv[1]):
540
+ d=json.loads(l)
541
+ if d["path"]=="/projects/p-1/epic-proposals":
542
+ b=d["body"]
543
+ print("SI" if b and json.loads(b).get("title")=="Notificaciones" else "NO")
544
+ break
545
+ PY
546
+ )"
547
+ [ "$body_ok" = SI ] && echo "OK una version de grafo no entera no vacia el payload" \
548
+ || { echo "FAIL el payload se vacio con una version de grafo invalida"; fail=1; }
549
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":42}}'
550
+
551
+ # [Important 3] Un 2xx del carril directo limpia el marcador de desfase: la petición llegó,
552
+ # así que ya no vamos rancios.
553
+ runtime_graph_note_stale 42 99
554
+ cat > "$TMP/routes-d.json" <<'JSON'
555
+ {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-101"}},
556
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
557
+ JSON
558
+ CID10="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
559
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
560
+ runtime_dispatch_outbox >/dev/null 2>&1
561
+ [ "$(runtime_graph_status_read)" = "{}" ] && echo "OK un 2xx del carril directo limpia el marcador de desfase" \
562
+ || { echo "FAIL el marcador de desfase sobrevive a un 2xx"; fail=1; }
563
+
564
+ # [Important 4] Un 409 que NO trae reason=stale_graph no se confunde con un grafo rancio: es
565
+ # un rechazo de contrato normal, apartado de inmediato, sin inventar un desfase.
566
+ cat > "$TMP/routes-d.json" <<'JSON'
567
+ {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "duplicate_proposal"}},
568
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
569
+ JSON
570
+ CID12="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
571
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
572
+ runtime_dispatch_outbox >/dev/null 2>&1
573
+ rej12="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected" -name "*$CID12.json" | head -1)"
574
+ [ -n "$rej12" ] && echo "OK un 409 sin reason=stale_graph se rechaza de inmediato (rechazo de contrato)" \
575
+ || { echo "FAIL un 409 no-stale_graph se trato como grafo rancio"; fail=1; }
576
+ python3 -c "
577
+ import json
578
+ print(json.load(open('$rej12')).get('rejected_reason',''))" 2>/dev/null | grep -qi 'grafo' \
579
+ && { echo "FAIL la razon del rechazo inventa un desfase de grafo que no existe"; fail=1; } \
580
+ || echo "OK la razon del rechazo no inventa un desfase de grafo"
581
+
582
+ # [Minor 6] "3 rechazos CONSECUTIVOS" es literal: un transitorio intercalado reinicia la racha.
583
+ cat > "$TMP/routes-d.json" <<'JSON'
584
+ {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 70}},
585
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
586
+ JSON
587
+ CID11="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
588
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
589
+ f11="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID11.json" | head -1)"
590
+ n1="$(python3 -c "import json; print(json.load(open('$f11')).get('graph_409'))" 2>/dev/null)"
591
+ [ "$n1" = 1 ] && echo "OK el primer 409 stale_graph cuenta 1" || { echo "FAIL primer conteo ($n1)"; fail=1; }
592
+
593
+ cat > "$TMP/routes-d.json" <<'JSON'
594
+ {"POST /projects/p-1/epic-proposals": {"status": 500, "body": {}},
595
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
596
+ JSON
597
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
598
+ n2="$(python3 -c "import json; print(json.load(open('$f11')).get('graph_409'))" 2>/dev/null)"
599
+ [ "$n2" = 0 ] && echo "OK un transitorio intercalado reinicia la racha de rechazos por grafo" \
600
+ || { echo "FAIL la racha no se reinicio ($n2)"; fail=1; }
601
+
602
+ cat > "$TMP/routes-d.json" <<'JSON'
603
+ {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 70}},
604
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
605
+ JSON
606
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
607
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
608
+ found11="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID11.json" | head -1)"
609
+ [ -n "$found11" ] && echo "OK tras el reinicio hacen falta 3 rechazos NUEVOS, no 2" \
610
+ || { echo "FAIL se aparto con solo 2 rechazos tras el reinicio"; fail=1; }
611
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
612
+ found11="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID11.json" | head -1)"
613
+ [ -z "$found11" ] && echo "OK el 3er rechazo consecutivo (tras el reinicio) aparta la peticion" \
614
+ || { echo "FAIL no se aparto tras 3 consecutivos reales"; fail=1; }
615
+
616
+ # ── Ronda final de la rama: A1 (idempotencia + mutex), A2 (racha por versión), B1, C4 ──
617
+
618
+ # [A1a] El carril directo viaja con `client_event_id` en el CUERPO: es la clave de
619
+ # idempotencia con la que el hub deduplica. Sin ella, dos despachos de la misma propuesta
620
+ # (daemon + proceso de la skill) crean DOS propuestas y, al aprobarlas, dos EP-XXX.
621
+ cat > "$TMP/routes-d.json" <<'JSON'
622
+ {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-200"}},
623
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
624
+ JSON
625
+ CID13="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
626
+ : > "$TMP/calls-d.jsonl"
627
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
628
+ runtime_dispatch_outbox >/dev/null 2>&1
629
+ sent_cid="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
630
+ import json,sys
631
+ for l in open(sys.argv[1]):
632
+ d=json.loads(l)
633
+ if d["path"]=="/projects/p-1/epic-proposals":
634
+ print(json.loads(d["body"]).get("client_event_id") or "")
635
+ break
636
+ PY
637
+ )"
638
+ [ "$sent_cid" = "$CID13" ] && echo "OK el carril directo manda client_event_id como clave de idempotencia" \
639
+ || { echo "FAIL el cuerpo del carril directo viaja sin client_event_id ($sent_cid vs $CID13)"; fail=1; }
640
+ sent_title="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
641
+ import json,sys
642
+ for l in open(sys.argv[1]):
643
+ d=json.loads(l)
644
+ if d["path"]=="/projects/p-1/epic-proposals":
645
+ print(json.loads(d["body"]).get("title") or "")
646
+ break
647
+ PY
648
+ )"
649
+ [ "$sent_title" = "Notificaciones" ] && echo "OK la clave de idempotencia no pisa el payload original" \
650
+ || { echo "FAIL el payload se perdio al estampar la clave ($sent_title)"; fail=1; }
651
+
652
+ # [A1b] Mutex del despacho: con el lock tomado por otro proceso, la cola NO se toca, no se
653
+ # abre socket y el rc dice que quedó trabajo (nunca 0 = "drenada" sin haberlo comprobado).
654
+ LOCK="$(runtime_dispatch_lock_path)"
655
+ rmdir "$LOCK" 2>/dev/null
656
+ CID14="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
657
+ : > "$TMP/calls-d.jsonl"
658
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
659
+ mkdir -p "$LOCK"
660
+ runtime_dispatch_outbox >/dev/null 2>&1
661
+ rc=$?
662
+ [ "$rc" -eq 1 ] && echo "OK con el mutex tomado el despacho no reporta la cola como drenada" \
663
+ || { echo "FAIL rc del despacho con el mutex tomado ($rc)"; fail=1; }
664
+ [ ! -s "$TMP/calls-d.jsonl" ] && echo "OK con el mutex tomado no se abre ningun socket" \
665
+ || { echo "FAIL el despacho ignoro el mutex y llamo al hub"; fail=1; }
666
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID14.json" | head -1)"
667
+ [ -n "$found" ] && echo "OK con el mutex tomado la cola queda intacta" \
668
+ || { echo "FAIL la cola se toco con el mutex tomado"; fail=1; }
669
+ rmdir "$LOCK" 2>/dev/null
670
+ runtime_dispatch_outbox >/dev/null 2>&1
671
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID14.json" | head -1)"
672
+ [ -z "$found" ] && echo "OK liberado el mutex, el despacho drena la cola" \
673
+ || { echo "FAIL la cola no drena tras liberar el mutex"; fail=1; }
674
+
675
+ # Un lock HUÉRFANO (proceso muerto entre el mkdir y el rmdir) se libera por edad: si no, la
676
+ # cola de ese repo no volvería a despacharse nunca.
677
+ CID15="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
678
+ mkdir -p "$LOCK"
679
+ touch -t 200001010000 "$LOCK"
680
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
681
+ runtime_dispatch_outbox >/dev/null 2>&1
682
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID15.json" | head -1)"
683
+ [ -z "$found" ] && echo "OK un mutex huerfano se libera por edad y la cola vuelve a drenar" \
684
+ || { echo "FAIL el mutex huerfano bloquea la cola para siempre"; fail=1; }
685
+ [ ! -d "$LOCK" ] && echo "OK el despacho suelta el mutex al terminar" \
686
+ || { echo "FAIL el mutex queda tomado tras el despacho"; fail=1; }
687
+
688
+ # [A2] La racha de 3 rechazos por grafo rancio cuenta rechazos contra la MISMA versión local.
689
+ # Si entre rechazo y rechazo hubo un refresco de verdad (cambió la versión estampada), la
690
+ # propuesta NO puede agotar sus tres vidas: es el caso real con `context_refresh_s: 0` o con
691
+ # tres turnos en menos de 45 s.
692
+ cat > "$TMP/routes-d.json" <<'JSON'
693
+ {"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 90}},
694
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
695
+ JSON
696
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":80}}'
697
+ CID16="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
698
+ for v in 81 82 83; do
699
+ # Cada ciclo despacha con una versión local NUEVA: es lo que haría el refresco del
700
+ # heartbeat entre turno y turno.
701
+ runtime_projection_write "{\"fetched_at\":\"2099-01-01T00:00:00Z\",\"context\":{\"graph_version\":$v}}"
702
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
703
+ runtime_dispatch_outbox >/dev/null 2>&1
704
+ done
705
+ f16="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID16.json" | head -1)"
706
+ [ -n "$f16" ] && echo "OK con la version local cambiando, la propuesta no agota la racha de 409" \
707
+ || { echo "FAIL se aparto la propuesta pese a que cada 409 fue contra otra version"; fail=1; }
708
+ n16="$(python3 -c "import json; print(json.load(open('$f16')).get('graph_409'))" 2>/dev/null)"
709
+ [ "$n16" = 1 ] && echo "OK la racha se reinicia en cada version local nueva" \
710
+ || { echo "FAIL la racha no se reinicio con la version nueva ($n16)"; fail=1; }
711
+ v16="$(python3 -c "import json; print(json.load(open('$f16')).get('graph_409_at_version'))" 2>/dev/null)"
712
+ [ "$v16" = 83 ] && echo "OK el marcador recuerda con que version local se rechazo" \
713
+ || { echo "FAIL el marcador de version de la racha ($v16)"; fail=1; }
714
+ # La misma versión tres veces SÍ agota la racha (el corte sigue existiendo).
715
+ for _ in 1 2 3; do
716
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
717
+ runtime_dispatch_outbox >/dev/null 2>&1
718
+ done
719
+ f16="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID16.json" | head -1)"
720
+ [ -z "$f16" ] && echo "OK tres 409 contra la MISMA version local siguen apartando la propuesta" \
721
+ || { echo "FAIL el corte de la racha desaparecio"; fail=1; }
722
+
723
+ # [B2] Un fichero SIN estampar que recibe un 409 no puede heredar la versión del fichero
724
+ # ANTERIOR del mismo lote: el marcador se graba con `local: null`, no con una versión inventada
725
+ # (ni con el 0, que tampoco es una versión real). El fixture necesita DOS ficheros directos en
726
+ # el MISMO despacho —uno estampado y otro no, en ese orden— porque `gv` es `local` a la función
727
+ # y con un solo fichero nace vacío igualmente: la fuga solo existe ENTRE iteraciones del bucle.
728
+ runtime_graph_clear_stale
729
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":77}}'
730
+ CID17A="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
731
+ CID17="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
732
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
733
+ runtime_dispatch_outbox >/dev/null 2>&1
734
+ # El orden FIFO (prefijo de timestamp) garantiza que el estampado se procesa primero; el
735
+ # marcador refleja el ÚLTIMO 409, o sea el del fichero sin estampar.
736
+ sent_gv="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
737
+ import json,sys
738
+ last=None
739
+ for l in open(sys.argv[1]):
740
+ d=json.loads(l)
741
+ if d["path"]=="/projects/p-1/epic-proposals":
742
+ last=json.loads(d["body"]).get("graph_version")
743
+ print("" if last is None else last)
744
+ PY
745
+ )"
746
+ [ -z "$sent_gv" ] && echo "OK el fichero sin estampar viaja sin graph_version" \
747
+ || { echo "FAIL el fichero sin estampar heredo una graph_version ($sent_gv)"; fail=1; }
748
+ lv="$(runtime_graph_status_read | python3 -c 'import json,sys; print(json.load(sys.stdin).get("local"))' 2>/dev/null)"
749
+ [ "$lv" = None ] && echo "OK sin version estampada, el desfase se anota como ausente (no 0 ni heredada)" \
750
+ || { echo "FAIL el desfase invento una version local ($lv)"; fail=1; }
751
+ v17="$(python3 -c "import json;print(json.load(open('$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID17.json" | head -1)')).get('graph_409_at_version'))" 2>/dev/null)"
752
+ [ "$v17" = None ] && echo "OK la racha del fichero sin estampar no hereda la version del anterior" \
753
+ || { echo "FAIL la racha heredo la version del fichero anterior ($v17)"; fail=1; }
754
+ find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 \( -name "*$CID17.json" -o -name "*$CID17A.json" \) -delete 2>/dev/null
755
+
756
+ # [B1] Un fichero ILEGIBLE de la cola se aparta con su razón en vez de quedarse atascado para
757
+ # siempre: como `batch` se saltaba en silencio al armar el lote y la cota nunca lo evictaba.
758
+ cat > "$TMP/routes-d.json" <<'JSON'
759
+ {"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
760
+ JSON
761
+ BADF="$CLAUDE_PROJECT_DIR/.claude/state/outbox/20990101000000000000-corrupto.json"
762
+ printf 'esto no es json' > "$BADF"
763
+ rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
764
+ err_bad="$(runtime_dispatch_outbox 2>&1 >/dev/null)"
765
+ [ ! -f "$BADF" ] && echo "OK un fichero ilegible sale de la cola" \
766
+ || { echo "FAIL el fichero ilegible sigue atascado en la cola"; fail=1; }
767
+ [ -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected/$(basename "$BADF")" ] \
768
+ && echo "OK el fichero ilegible se aparta a rejected/" \
769
+ || { echo "FAIL el fichero ilegible no llego a rejected/"; fail=1; }
770
+ echo "$err_bad" | grep -qi 'ilegible' && echo "OK el apartado del fichero ilegible nombra la causa" \
771
+ || { echo "FAIL el apartado del ilegible no dice por que ($err_bad)"; fail=1; }
772
+
773
+ # [C4] Si la partición de la cola falla (E2BIG con una cola enorme, python caído), el despacho
774
+ # NO puede devolver 0 con la cola llena ni borrar el backoff agendado: eso es un fallo
775
+ # reportado como drenaje limpio.
776
+ CID18="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
777
+ python3 -c "import json;json.dump({'next_attempt_epoch':0,'attempt':2}, open('$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json','w'))"
778
+ _runtime_partition_outbox() { return 1; }
779
+ err_part="$(runtime_dispatch_outbox 2>&1 >/dev/null)"
780
+ rc=$?
781
+ [ "$rc" -ne 0 ] && echo "OK una particion fallida no se reporta como cola drenada" \
782
+ || { echo "FAIL rc 0 con la particion fallida"; fail=1; }
783
+ echo "$err_part" | grep -qi 'clasificar' && echo "OK la particion fallida se dice en stderr" \
784
+ || { echo "FAIL la particion fallida es muda ($err_part)"; fail=1; }
785
+ [ -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json" ] \
786
+ && echo "OK la particion fallida conserva el backoff agendado" \
787
+ || { echo "FAIL el backoff se borro con la particion fallida"; fail=1; }
788
+ found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID18.json" | head -1)"
789
+ [ -n "$found" ] && echo "OK la particion fallida conserva la cola" \
790
+ || { echo "FAIL la cola se perdio con la particion fallida"; fail=1; }
791
+ unset -f _runtime_partition_outbox
792
+ source "$ROOT/hooks/build/lib/runtime-client.sh"
793
+
794
+ kill "$STUB_D" 2>/dev/null; wait "$STUB_D" 2>/dev/null
795
+
796
+ unset CLAUDE_PROJECT_DIR BUILD_CONFIG_FILE
797
+
298
798
  exit $fail