@trycore/spec-build-harness 0.11.1 → 0.13.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.
@@ -83,7 +83,7 @@ rm -f "$PROJ/.claude/state/outbox/"*.json
83
83
  # protegidos porque la capa de compat del despacho los traduce y entrega.)
84
84
  for t in slice_archived wiring_item_updated slice_escalated gate_verdict checkpoint_recorded \
85
85
  project_fact_updated wiring_checklist_seeded slice_submitted handoff_recorded telemetry_gap \
86
- checkpoint_created verdict_reported escalation_raised; do
86
+ phase_advanced checkpoint_created verdict_reported escalation_raised; do
87
87
  case " $RUNTIME_OUTBOX_PROTECTED " in
88
88
  *" $t "*) : ;;
89
89
  *) echo "FAIL $t no está en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
@@ -394,6 +394,28 @@ rc=$?
394
394
  echo "$out" | grep -q "modo:" && echo "$out" | grep -q "sl-7" && echo "$out" | grep -qi "cola" \
395
395
  && echo "OK status informa modo, slice y cola" || { echo "FAIL status incompleto: $out"; fail=1; }
396
396
 
397
+ # [issue #52] Visibilidad de outbox/rejected/: sin rechazados, status lo dice («ninguno»);
398
+ # con rechazados, reporta el conteo y la RAZÓN del más reciente — hoy un slice_archived
399
+ # rechazado era invisible (cola 0 pendientes = «éxito» aparente).
400
+ echo "$out" | grep -qi "rechazados" && echo "$out" | grep -qi "ninguno" \
401
+ && echo "OK status sin rechazados reporta «ninguno»" || { echo "FAIL status sin línea de rechazados: $out"; fail=1; }
402
+ mkdir -p "$PROJ/.claude/state/outbox/rejected"
403
+ cat > "$PROJ/.claude/state/outbox/rejected/20260828000000000001-cid-rej-1.json" <<'JSON'
404
+ {"client_event_id": "cid-rej-1", "type": "progress_noted", "payload": {"note": "x"},
405
+ "enqueued_at": "2026-08-28T00:00:00.000000Z", "rejected_reason": "razón vieja"}
406
+ JSON
407
+ cat > "$PROJ/.claude/state/outbox/rejected/20260828000000000002-cid-rej-2.json" <<'JSON'
408
+ {"client_event_id": "cid-rej-2", "type": "slice_archived", "payload": {},
409
+ "enqueued_at": "2026-08-28T00:00:01.000000Z", "slice_id": "sl-7",
410
+ "rejected_reason": "archivar exige estar en la fase pr"}
411
+ JSON
412
+ out="$(bash "$SO" status --no-refresh)"
413
+ echo "$out" | grep -q "2 evento(s)" && echo "$out" | grep -q "⚠" \
414
+ && echo "OK status reporta el conteo de rechazados con ⚠" || { echo "FAIL status sin conteo de rechazados: $out"; fail=1; }
415
+ echo "$out" | grep -q "slice_archived" && echo "$out" | grep -q "archivar exige estar en la fase pr" \
416
+ && echo "OK status muestra tipo y razón del rechazo más reciente" || { echo "FAIL status sin razón del último rechazo: $out"; fail=1; }
417
+ rm -rf "$PROJ/.claude/state/outbox/rejected"
418
+
397
419
  # status es informativo: NUNCA falla aunque no haya nada.
398
420
  rm -f "$PROJ/.claude/state/runtime-projection.json"
399
421
  rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" status --no-refresh)" || rc=$?
@@ -430,20 +452,20 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
430
452
  || { echo "FAIL escalate con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
431
453
  rm -f "$PROJ/.claude/state/outbox/"*.json
432
454
 
433
- # usage: lista los 13 subcomandos REALMENTE implementados (contrato con la prosa de las
434
- # skills) — el plan queda completo con la Task 4 (wiring/progress/checkpoint).
455
+ # usage: lista los 14 subcomandos REALMENTE implementados (contrato con la prosa de las
456
+ # skills) — 13 del plan EP-OR-08-C + `phase` (issue #52).
435
457
  usage_out="$(bash "$SO" --help 2>&1)"
436
- for sub in mode claim next-step gate submit archive wiring progress checkpoint fact propose-asset status escalate; do
458
+ for sub in mode claim next-step phase gate submit archive wiring progress checkpoint fact propose-asset status escalate; do
437
459
  echo "$usage_out" | grep -q -- "$sub" || { echo "FAIL usage no documenta $sub"; fail=1; }
438
460
  done
439
461
  echo "$usage_out" | grep -qi "pendientes" \
440
462
  && { echo "FAIL usage todavía anuncia pendientes tras completar la Task 4"; fail=1; } \
441
- || echo "OK usage documenta los 13 subcomandos como implementados, sin pendientes"
463
+ || echo "OK usage documenta los 14 subcomandos como implementados, sin pendientes"
442
464
 
443
465
  # Regresión de modo LEGACY sobre TODOS los subcomandos IMPLEMENTADOS que transicionan:
444
- # rc 3 y cero red (los 13, ya con wiring/progress/checkpoint de la Task 4).
466
+ # rc 3 y cero red (los 14, ya con phase del issue #52).
445
467
  : > "$TMP/curl-legacy.log"
446
- for sub in "gate tdd pass" "submit" "archive" "wiring update w1 passing" "progress algo" "checkpoint --note algo" "fact project-kind greenfield" "propose-asset --type-key t --path p --content-file $TMP/asset.md" "escalate algo --gate fidelity"; do
468
+ for sub in "phase pr" "gate tdd pass" "submit" "archive" "wiring update w1 passing" "progress algo" "checkpoint --note algo" "fact project-kind greenfield" "propose-asset --type-key t --path p --content-file $TMP/asset.md" "escalate algo --gate fidelity"; do
447
469
  rc=0
448
470
  bash -c "PATH=\"$TMP/fakebin:\$PATH\" FAKE_CURL_LOG=\"$TMP/curl-legacy.log\" TRYCORE_RUNTIME_MODE=legacy CLAUDE_PROJECT_DIR=\"$PROJ\" BUILD_CONFIG_FILE=\"$BUILD_CONFIG_FILE\" bash \"$SO\" $sub" >/dev/null 2>&1 || rc=$?
449
471
  [ "$rc" = 3 ] || { echo "FAIL «${sub}» en legacy devolvió $rc (esperado 3)"; fail=1; }
@@ -694,6 +716,186 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
694
716
  || { echo "FAIL archive con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
695
717
  rm -f "$PROJ/.claude/state/outbox/"*.json
696
718
 
719
+ # ══════════════ slice-ops.sh: phase (cadena de fases del hub) [issue #52] ══════════════
720
+ # El hub modela la fase con `phase_advanced {to}` de ORDEN ESTRICTO (domain.py:400-407,
721
+ # PHASES dor→…→pr→archived) y `slice_archived` exige phase == pr (domain.py:499-500).
722
+ # Sin este subcomando el slice se quedaba en dor server-side y el archivado caía a
723
+ # rejected/ EN SILENCIO (piloto 2026-08-28).
724
+
725
+ # Sin argumento: uso incorrecto.
726
+ rc=0; bash "$SO" phase >/dev/null 2>&1 || rc=$?
727
+ [ "$rc" = 2 ] && echo "OK phase sin argumento -> rc 2" || { echo "FAIL phase sin argumento rc ($rc)"; fail=1; }
728
+
729
+ # Fase desconocida (o archived, que no se reporta por aquí): rechazo LOCAL, sin red ni cola.
730
+ rm -f "$PROJ/.claude/state/outbox/"*.json
731
+ : > "$TMP/calls-ga.jsonl"
732
+ rc=0; bash "$SO" phase inventada >/dev/null 2>&1 || rc=$?
733
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
734
+ [ "$rc" = 6 ] && [ "$n" = 0 ] && echo "OK phase desconocida -> rc 6 sin encolar" || { echo "FAIL phase desconocida (rc=$rc n=$n)"; fail=1; }
735
+ rc=0; bash "$SO" phase archived >/dev/null 2>&1 || rc=$?
736
+ [ "$rc" = 6 ] && echo "OK phase archived -> rc 6 (archived se alcanza con archive, no con phase)" || { echo "FAIL phase archived rc ($rc)"; fail=1; }
737
+
738
+ # Runtime, un paso: el servidor conoce «red» -> `phase green` encola EXACTAMENTE un
739
+ # phase_advanced {to: green} con slice_id.
740
+ python3 - "$TMP/routes-ga.json" <<'PYX'
741
+ import json,sys
742
+ p=sys.argv[1]; d=json.load(open(p))
743
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"red","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
744
+ json.dump(d,open(p,"w"))
745
+ PYX
746
+ rm -f "$PROJ/.claude/state/outbox/"*.json
747
+ rc=0; out="$(bash "$SO" phase green)" || rc=$?
748
+ [ "$rc" = 0 ] && echo "OK phase green -> rc 0" || { echo "FAIL phase green rc ($rc)"; fail=1; }
749
+ [ "$(python3 - "$PROJ/.claude/state/outbox" <<'PYX'
750
+ import json,sys,glob,os
751
+ evs=[json.load(open(p)) for p in sorted(glob.glob(os.path.join(sys.argv[1],"*.json"))) if not os.path.basename(p).startswith(".")]
752
+ pa=[e for e in evs if e.get("type")=="phase_advanced"]
753
+ ok = len(evs)==1 and len(pa)==1 and pa[0]["payload"]=={"to":"green"} and pa[0].get("slice_id")=="sl-7"
754
+ print("SI" if ok else "NO")
755
+ PYX
756
+ )" = SI ] && echo "OK phase green encola un único phase_advanced {to} con slice_id" \
757
+ || { echo "FAIL evento(s) de phase green"; fail=1; }
758
+
759
+ # Cadena con huecos: el servidor sigue en «red»; `phase pr` emite green→…→pr EN ORDEN
760
+ # (el orden estricto del hub no admite saltos) y avisa del hueco.
761
+ rm -f "$PROJ/.claude/state/outbox/"*.json
762
+ rc=0; out="$(bash "$SO" phase pr 2>&1)" || rc=$?
763
+ [ "$rc" = 0 ] && echo "OK phase pr con huecos -> rc 0" || { echo "FAIL phase pr rc ($rc)"; fail=1; }
764
+ [ "$(python3 - "$PROJ/.claude/state/outbox" <<'PYX'
765
+ import json,sys,glob,os
766
+ tos=[json.load(open(p))["payload"].get("to") for p in sorted(glob.glob(os.path.join(sys.argv[1],"*.json")))
767
+ if not os.path.basename(p).startswith(".") and json.load(open(p)).get("type")=="phase_advanced"]
768
+ print("SI" if tos==["green","refactor","smoke","api","data","dod","pr"] else "NO:"+",".join(str(t) for t in tos))
769
+ PYX
770
+ )" = SI ] && echo "OK phase pr encola la cadena green→…→pr en orden" || { echo "FAIL cadena de phase pr"; fail=1; }
771
+ echo "$out" | grep -q "⚠" && echo "OK phase pr avisa del hueco rellenado" || { echo "FAIL phase pr sin aviso de hueco ($out)"; fail=1; }
772
+
773
+ # Idempotencia contra la COLA: la cadena ya está pendiente en la outbox -> repetir
774
+ # `phase pr` no encola nada nuevo (el daemon aún no drenó, pero el cliente ya lo sabe).
775
+ n_antes="$(find "$PROJ/.claude/state/outbox" -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
776
+ rc=0; bash "$SO" phase pr >/dev/null 2>&1 || rc=$?
777
+ n_despues="$(find "$PROJ/.claude/state/outbox" -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
778
+ [ "$rc" = 0 ] && [ "$n_antes" = "$n_despues" ] \
779
+ && echo "OK phase pr repetido es idempotente (cuenta la cola pendiente)" \
780
+ || { echo "FAIL idempotencia por cola (antes=$n_antes despues=$n_despues rc=$rc)"; fail=1; }
781
+
782
+ # Idempotencia contra la PROYECCIÓN: el servidor ya está en pr -> nada que emitir.
783
+ python3 - "$TMP/routes-ga.json" <<'PYX'
784
+ import json,sys
785
+ p=sys.argv[1]; d=json.load(open(p))
786
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"pr","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
787
+ json.dump(d,open(p,"w"))
788
+ PYX
789
+ rm -f "$PROJ/.claude/state/outbox/"*.json
790
+ rc=0; out="$(bash "$SO" phase pr)" || rc=$?
791
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
792
+ [ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase pr con el servidor ya en pr -> no-op idempotente" \
793
+ || { echo "FAIL idempotencia por proyección (rc=$rc n=$n out=$out)"; fail=1; }
794
+
795
+ # Catch-up en archive [issue #52]: slice_archived exige phase == pr — si el servidor quedó
796
+ # atrás (pipeline corrido con una skill vieja, sin llamadas a `phase`), archive emite la
797
+ # cadena que falte ANTES del archivado, con aviso. Es el workaround del piloto hecho sistema.
798
+ python3 - "$TMP/routes-ga.json" <<'PYX'
799
+ import json,sys
800
+ p=sys.argv[1]; d=json.load(open(p))
801
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"dor","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
802
+ json.dump(d,open(p,"w"))
803
+ PYX
804
+ agent_context_fetch_and_cache >/dev/null
805
+ rm -f "$PROJ/.claude/state/outbox/"*.json
806
+ rc=0; out="$(bash "$SO" archive --epic EP-003 2>&1)" || rc=$?
807
+ [ "$rc" = 0 ] && echo "OK archive con catch-up -> rc 0" || { echo "FAIL archive catch-up rc ($rc)"; fail=1; }
808
+ [ "$(python3 - "$PROJ/.claude/state/outbox" <<'PYX'
809
+ import json,sys,glob,os
810
+ evs=[json.load(open(p)) for p in sorted(glob.glob(os.path.join(sys.argv[1],"*.json"))) if not os.path.basename(p).startswith(".")]
811
+ tos=[e["payload"].get("to") for e in evs if e.get("type")=="phase_advanced"]
812
+ tipos=[e.get("type") for e in evs]
813
+ ok = tos==["change","red","green","refactor","smoke","api","data","dod","pr"] \
814
+ and tipos[-1]=="slice_archived" and all(t=="phase_advanced" for t in tipos[:-1]) \
815
+ and all(e.get("slice_id")=="sl-7" for e in evs)
816
+ print("SI" if ok else "NO:"+",".join(tipos))
817
+ PYX
818
+ )" = SI ] && echo "OK archive encola la cadena dor→…→pr ANTES de slice_archived" \
819
+ || { echo "FAIL catch-up de archive"; fail=1; }
820
+ echo "$out" | grep -q "server-side" && echo "OK archive avisa del catch-up (slice atrasado server-side)" \
821
+ || { echo "FAIL archive sin aviso de catch-up ($out)"; fail=1; }
822
+ rm -f "$PROJ/.claude/state/outbox/"*.json
823
+
824
+ # archive con el servidor ya en pr: SOLO slice_archived, sin cadena ni aviso.
825
+ python3 - "$TMP/routes-ga.json" <<'PYX'
826
+ import json,sys
827
+ p=sys.argv[1]; d=json.load(open(p))
828
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"pr","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
829
+ json.dump(d,open(p,"w"))
830
+ PYX
831
+ agent_context_fetch_and_cache >/dev/null
832
+ rc=0; out="$(bash "$SO" archive --epic EP-003 2>&1)" || rc=$?
833
+ n_pa="$(grep -l '"type": "phase_advanced"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | wc -l | tr -d ' ')"
834
+ n_ar="$(grep -l '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | wc -l | tr -d ' ')"
835
+ [ "$rc" = 0 ] && [ "$n_pa" = 0 ] && [ "$n_ar" = 1 ] && ! echo "$out" | grep -q "server-side" \
836
+ && echo "OK archive con el servidor en pr no emite cadena ni aviso" \
837
+ || { echo "FAIL archive sin catch-up (rc=$rc pa=$n_pa ar=$n_ar)"; fail=1; }
838
+ rm -f "$PROJ/.claude/state/outbox/"*.json
839
+
840
+ # dual: phase ESPEJA (phase_advanced ∈ TIPOS_DE_ESPEJO del hub) con la identidad local,
841
+ # sin tocar la cola — mismo patrón que gate/submit en dual.
842
+ cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
843
+ {"version":"1.0.0","active_slice":{"epica":"EP-009","openspec_change":"add-pagos","branch":"feature/pagos","phase":"green","gates":{"dor":true}}}
844
+ JSON
845
+ python3 - "$TMP/routes-ga.json" <<'PYX'
846
+ import json,sys
847
+ p=sys.argv[1]; d=json.load(open(p))
848
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"red","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
849
+ json.dump(d,open(p,"w"))
850
+ PYX
851
+ : > "$TMP/calls-ga.jsonl"
852
+ rm -f "$PROJ/.claude/state/outbox/"*.json
853
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$SO" phase green >/dev/null 2>&1 || rc=$?
854
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
855
+ [ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase en dual -> rc 0 sin encolar" || { echo "FAIL phase dual (rc=$rc n=$n)"; fail=1; }
856
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
857
+ import json,sys
858
+ tos=[]
859
+ for line in open(sys.argv[1]):
860
+ c=json.loads(line)
861
+ if c["path"]=="/projects/p-1/mirror/transitions":
862
+ b=json.loads(c["body"])
863
+ if b.get("kind")=="phase_advanced":
864
+ tos.append((b["payload"].get("to"), b["payload"].get("slice_ref",{}).get("epic_code")))
865
+ print("SI" if tos==[("green","EP-009")] else "NO:"+repr(tos))
866
+ PYX
867
+ )" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con slice_ref" \
868
+ || { echo "FAIL espejo de phase en dual"; fail=1; }
869
+
870
+ # dual: archive también hace catch-up por el espejo (cadena red→…→pr antes del archivado).
871
+ : > "$TMP/calls-ga.jsonl"
872
+ rc=0; out="$(TRYCORE_RUNTIME_MODE=dual bash "$SO" archive --epic EP-009 2>&1)" || rc=$?
873
+ [ "$rc" = 0 ] && echo "$out" | grep -q "server-side" \
874
+ && echo "OK archive en dual avisa del catch-up" || { echo "FAIL archive dual catch-up (rc=$rc out=$out)"; fail=1; }
875
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
876
+ import json,sys
877
+ kinds=[]
878
+ for line in open(sys.argv[1]):
879
+ c=json.loads(line)
880
+ if c["path"]=="/projects/p-1/mirror/transitions":
881
+ b=json.loads(c["body"])
882
+ kinds.append((b.get("kind"), b["payload"].get("to")))
883
+ esperado=[("phase_advanced",t) for t in ["green","refactor","smoke","api","data","dod","pr"]]+[("slice_archived",None)]
884
+ print("SI" if kinds==esperado else "NO:"+repr(kinds))
885
+ PYX
886
+ )" = SI ] && echo "OK archive en dual espeja la cadena hasta pr y luego slice_archived" \
887
+ || { echo "FAIL cadena del archive dual"; fail=1; }
888
+
889
+ # Restaura la ruta y la caché al estado que esperan los bloques siguientes (sl-7, fase tdd).
890
+ python3 - "$TMP/routes-ga.json" <<'PYX'
891
+ import json,sys
892
+ p=sys.argv[1]; d=json.load(open(p))
893
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"tdd","gates":{"dor":True,"tdd":False}},"context":{"manifest_hash":"sha256:v1"}}}
894
+ json.dump(d,open(p,"w"))
895
+ PYX
896
+ agent_context_fetch_and_cache >/dev/null
897
+ rm -f "$PROJ/.claude/state/outbox/"*.json
898
+
697
899
  # dual: el fichero es primario -> se espeja la transición con la identidad LOCAL.
698
900
  cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
699
901
  {"version":"1.0.0","active_slice":{"epica":"EP-009","openspec_change":"add-pagos","branch":"feature/pagos","phase":"tdd","gates":{"dor":true}}}
@@ -1036,6 +1238,14 @@ nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc
1036
1238
  [ "$n" = 0 ] && [ "$nr" = 1 ] \
1037
1239
  && echo "OK el elemento rechazado por el hub se aparta a rejected/ (no se reenvía)" \
1038
1240
  || { echo "FAIL elemento rechazado (pendientes=$n rechazados=$nr)"; fail=1; }
1241
+ # [issue #52] La razón del hub queda PERSISTIDA en el fichero apartado (rejected_reason):
1242
+ # es lo que `slice-ops.sh status` y `trycore-build status/doctor` muestran después.
1243
+ fr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' | head -1)"
1244
+ [ -n "$fr" ] && [ "$(python3 -c "
1245
+ import json;d=json.load(open('$fr'))
1246
+ print('SI' if 'desconocido para el catálogo v2' in (d.get('rejected_reason') or '') else 'NO')")" = SI ] \
1247
+ && echo "OK el fichero apartado conserva la razón del hub (rejected_reason)" \
1248
+ || { echo "FAIL rejected/ sin rejected_reason persistida"; fail=1; }
1039
1249
  # [#44] El fichero 0.10.x `verdict_reported {gate, status}` viajó TRADUCIDO al catálogo v2
1040
1250
  # (gate_verdict {gate, verdict: PASS}) — la traducción vive en el único punto de salida.
1041
1251
  [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
@@ -1107,6 +1317,15 @@ nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc
1107
1317
  [ "$rc" = 0 ] && [ "$n" = 0 ] && [ "$nr" = 3 ] && ! grep -q '"path": "/events"' "$TMP/calls-ga.jsonl" \
1108
1318
  && echo "OK los tipos sin equivalente se apartan localmente a rejected/ sin tocar la red" \
1109
1319
  || { echo "FAIL pre-filtro de tipos sin equivalente (rc=$rc pendientes=$n rechazados=$nr)"; fail=1; }
1320
+ # [issue #52] También el apartado local persiste su razón (rejected_reason).
1321
+ [ "$(python3 - "$PROJ/.claude/state/outbox/rejected" <<'PYX'
1322
+ import json,glob,os,sys
1323
+ ok=all("catálogo" in (json.load(open(p)).get("rejected_reason") or "")
1324
+ for p in glob.glob(os.path.join(sys.argv[1],"*.json")))
1325
+ print("SI" if ok else "NO")
1326
+ PYX
1327
+ )" = SI ] && echo "OK los tipos sin equivalente llevan rejected_reason" \
1328
+ || { echo "FAIL apartado local sin rejected_reason"; fail=1; }
1110
1329
  rm -rf "$PROJ/.claude/state/outbox/rejected"
1111
1330
 
1112
1331
  # [#44] telemetry_gap de la evicción con el payload del catálogo: {dropped, window_h,
@@ -1154,6 +1373,13 @@ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.disp
1154
1373
  nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
1155
1374
  [ "$n" = 0 ] && [ "$nr" = 1 ] && echo "OK el lote 4xx se mueve a rejected/ y no se reintenta" \
1156
1375
  || { echo "FAIL lote 4xx (pendientes=$n rechazados=$nr)"; fail=1; }
1376
+ # [issue #52] El lote 4xx también persiste su razón (HTTP status) en cada fichero apartado.
1377
+ fr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' | head -1)"
1378
+ [ -n "$fr" ] && [ "$(python3 -c "
1379
+ import json;d=json.load(open('$fr'))
1380
+ print('SI' if '422' in (d.get('rejected_reason') or '') else 'NO')")" = SI ] \
1381
+ && echo "OK el lote 4xx conserva la razón (HTTP 422) en rejected_reason" \
1382
+ || { echo "FAIL lote 4xx sin rejected_reason"; fail=1; }
1157
1383
  echo "$err" | grep -q "dict_type" && echo "OK el 4xx loggea el cuerpo del error del hub" \
1158
1384
  || { echo "FAIL 4xx sin cuerpo del error ($err)"; fail=1; }
1159
1385
  [ ! -f "$PROJ/.claude/state/outbox/.dispatch-state.json" ] \
@@ -1202,6 +1428,7 @@ CATALOGO = {
1202
1428
  "handoff_recorded": {"claves": {"note", "resume_hint", "stopped_at"},
1203
1429
  "obligatorias": {"note"}, "ambito": "slice"},
1204
1430
  "slice_escalated": {"claves": {"cause"}, "obligatorias": {"cause"}, "ambito": "slice"},
1431
+ "phase_advanced": {"claves": {"to"}, "obligatorias": {"to"}, "ambito": "slice"},
1205
1432
  "slice_submitted": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
1206
1433
  "slice_archived": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
1207
1434
  "wiring_checklist_seeded": {"claves": {"items"}, "obligatorias": {"items"}, "ambito": "slice"},
@@ -1240,7 +1467,8 @@ fuera=sorted(t for t in encontrados if t not in catalogo)
1240
1467
  # el guard está ciego y también es un fallo.
1241
1468
  esperados={"gate_verdict","checkpoint_recorded","progress_noted","slice_escalated",
1242
1469
  "slice_submitted","slice_archived","wiring_checklist_seeded",
1243
- "wiring_item_updated","project_fact_updated","handoff_recorded"}
1470
+ "wiring_item_updated","project_fact_updated","handoff_recorded",
1471
+ "phase_advanced"}
1244
1472
  ciegos=sorted(esperados-encontrados)
1245
1473
  if fuera:
1246
1474
  print("FUERA_DE_CATALOGO:"+",".join(fuera))
@@ -1258,6 +1486,7 @@ PYX
1258
1486
  runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
1259
1487
  rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
1260
1488
  bash "$SO" gate tdd pass --evidence "verde" >/dev/null 2>&1
1489
+ bash "$SO" phase red >/dev/null 2>&1
1261
1490
  bash "$SO" submit --pr-url "https://example.invalid/pr/9" >/dev/null 2>&1
1262
1491
  bash "$SO" archive --epic EP-003 >/dev/null 2>&1
1263
1492
  bash "$SO" wiring seed --file "$TMP/items-small.json" >/dev/null 2>&1
@@ -1303,7 +1532,7 @@ for path in sorted(glob.glob(os.path.join(sys.argv[2],"*.json"))):
1303
1532
  errores.append("wiring_checklist_seeded: item fuera de schema %r" % it); break
1304
1533
  esperados={"gate_verdict","slice_submitted","slice_archived","wiring_checklist_seeded",
1305
1534
  "wiring_item_updated","progress_noted","checkpoint_recorded","slice_escalated",
1306
- "project_fact_updated","telemetry_gap"}
1535
+ "project_fact_updated","telemetry_gap","phase_advanced"}
1307
1536
  faltantes=sorted(esperados-vistos)
1308
1537
  if faltantes:
1309
1538
  errores.append("productores sin evento encolado (¿cambió un subcomando?): %s" % faltantes)
@@ -1364,4 +1593,122 @@ echo 'no-json' | python3 "$GB" > /dev/null 2> "$TMP/gb3.err"
1364
1593
  rc=$?
1365
1594
  [ "$rc" = 1 ] && ! grep -q "Traceback" "$TMP/gb3.err" && echo "OK graph-bundle con entrada ilegible -> rc 1 limpio" || { echo "FAIL entrada ilegible ($rc)"; fail=1; }
1366
1595
 
1596
+ # ══════════ graph-bundle.py --from-docs / --print-deps [issue #56] ══════════
1597
+ # El grafo llegaba al hub con `depends_on: []` (41/41 épicas en el piloto): la
1598
+ # extracción vivía en el LLM y el ejemplo de onboard anclaba en []. El extractor
1599
+ # es ahora DETERMINISTA y lee la prosa de `docs/03-backlog/epicas.md`.
1600
+ mkdir -p "$TMP/docs"
1601
+ cat > "$TMP/docs/epicas.md" <<'MD'
1602
+ # Backlog — épicas
1603
+
1604
+ **Dependencias entre épicas**: EP-999 (esto está FUERA de toda sección: se ignora)
1605
+
1606
+ ## EP-001 — Cimiento
1607
+
1608
+ **Depende de**: nada.
1609
+
1610
+ **Resumen**: base técnica.
1611
+
1612
+ ---
1613
+
1614
+ ## EP-002 — Cobros
1615
+
1616
+ **Anexo A**: Fase 3 · **Depende de**: **EP-001** (base de datos), EP-404 (no existe en el grafo)
1617
+
1618
+ **Resumen**: cobra.
1619
+
1620
+ ## EP-003 — Reportes
1621
+
1622
+ **Depende de**: EP-002 (pipeline de export),
1623
+ EP-001 (cimiento). Ninguna bloquea: todas archivadas.
1624
+
1625
+ **Resumen**: reporta.
1626
+
1627
+ ## EP-004 — Estructurada
1628
+
1629
+ depende_de: [EP-003]
1630
+
1631
+ **Depende de**: EP-001 en prosa — gana el campo estructurado.
1632
+
1633
+ ## EP-005 — Autorreferente
1634
+
1635
+ **Depende de**: EP-005 (a sí misma: se excluye), EP-001
1636
+ MD
1637
+
1638
+ # 1) --print-deps: extracción pura, determinista, inspeccionable.
1639
+ out="$(python3 "$GB" --print-deps "$TMP/docs/epicas.md" 2> "$TMP/gb4.err")"
1640
+ rc=$?
1641
+ # `**Depende de**: nada.` es una declaración, no una ausencia: entra con lista vacía.
1642
+ esperado='{"EP-001": [], "EP-002": ["EP-001", "EP-404"], "EP-003": ["EP-001", "EP-002"], "EP-004": ["EP-003"], "EP-005": ["EP-001"]}'
1643
+ [ "$rc" = 0 ] && [ "$(echo "$out" | python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin),sort_keys=True))')" = "$esperado" ] \
1644
+ && echo "OK --print-deps extrae de la prosa (multilínea, campo estructurado, sin autorreferencia)" \
1645
+ || { echo "FAIL --print-deps ($rc) -> $out"; fail=1; }
1646
+
1647
+ # 2) --from-docs puebla el grafo aunque el JSON del modelo traiga depends_on vacíos.
1648
+ cat > "$TMP/epics56.json" <<'JSON'
1649
+ {"project_ref":"acme","epics":[
1650
+ {"code":"EP-001","title":"Cimiento","layer":"foundational","depends_on":[]},
1651
+ {"code":"EP-002","title":"Cobros","layer":"business","depends_on":[]},
1652
+ {"code":"EP-003","title":"Reportes","layer":"business","depends_on":[]},
1653
+ {"code":"EP-004","title":"Estructurada","layer":"business","depends_on":["EP-002"]}
1654
+ ]}
1655
+ JSON
1656
+ out="$(python3 "$GB" --from-docs "$TMP/docs/epicas.md" < "$TMP/epics56.json" 2> "$TMP/gb5.err")"
1657
+ rc=$?
1658
+ [ "$rc" = 0 ] && [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(";".join(e["code"]+"="+",".join(e["depends_on"]) for e in d["epics"]))')" = "EP-001=;EP-002=EP-001,EP-404;EP-003=EP-001,EP-002;EP-004=EP-002,EP-003" ] \
1659
+ && echo "OK --from-docs puebla depends_on desde los docs (unión con lo declarado)" \
1660
+ || { echo "FAIL --from-docs ($rc) -> $out"; fail=1; }
1661
+ grep -q "EP-404" "$TMP/gb5.err" \
1662
+ && echo "OK --from-docs mantiene el aviso de dependencia inexistente" || { echo "FAIL sin aviso de dep inexistente con --from-docs"; fail=1; }
1663
+ grep -q "EP-005" "$TMP/gb5.err" \
1664
+ && echo "OK --from-docs avisa de la épica que está en docs pero no en el JSON" || { echo "FAIL sin aviso de épica ausente del JSON"; fail=1; }
1665
+ echo "$out" | grep -q '"depends_on_source"' \
1666
+ && echo "OK el bundle deja traza de la fuente de dependencias" || { echo "FAIL el bundle no declara depends_on_source"; fail=1; }
1667
+
1668
+ # 3) Los ciclos que vienen de la prosa se cazan igual (el aviso ya existía).
1669
+ cat > "$TMP/docs/ciclo.md" <<'MD'
1670
+ ## EP-010 — A
1671
+ **Depende de**: EP-011
1672
+ ## EP-011 — B
1673
+ **Depende de**: EP-010
1674
+ MD
1675
+ echo '{"epics":[{"code":"EP-010","title":"A","layer":"business"},{"code":"EP-011","title":"B","layer":"business"}]}' \
1676
+ | python3 "$GB" --from-docs "$TMP/docs/ciclo.md" > /dev/null 2> "$TMP/gb6.err"
1677
+ rc=$?
1678
+ [ "$rc" = 0 ] && grep -qi "ciclo" "$TMP/gb6.err" \
1679
+ && echo "OK --from-docs + ciclo de la prosa -> aviso, sin bloquear" || { echo "FAIL ciclo desde docs ($rc)"; fail=1; }
1680
+
1681
+ # 4) Ruta ilegible: rc 1 con mensaje, jamás traceback ni silencio (el fallo del piloto
1682
+ # fue precisamente un grafo vacío que no falló).
1683
+ echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/no-existe.md" > /dev/null 2> "$TMP/gb7.err"
1684
+ rc=$?
1685
+ [ "$rc" = 1 ] && ! grep -q "Traceback" "$TMP/gb7.err" \
1686
+ && echo "OK --from-docs con ruta ilegible -> rc 1 limpio" || { echo "FAIL ruta ilegible ($rc)"; fail=1; }
1687
+ # Flag sin argumento: mismo trato.
1688
+ echo '{"epics":[]}' | python3 "$GB" --from-docs > /dev/null 2> "$TMP/gb8.err"
1689
+ [ "$?" = 1 ] && ! grep -q "Traceback" "$TMP/gb8.err" \
1690
+ && echo "OK --from-docs sin argumento -> rc 1 limpio" || { echo "FAIL --from-docs sin argumento"; fail=1; }
1691
+
1692
+ # 4-bis) Sección presente pero SIN el campo: aviso distinto al de sección ausente
1693
+ # (un backlog que dibuja sus dependencias en ASCII no es un backlog sin épicas).
1694
+ printf '## EP-020 — Con seccion, sin campo\n\n**Resumen**: x.\n' > "$TMP/docs/sin-campo.md"
1695
+ echo '{"epics":[{"code":"EP-020","title":"X","layer":"business"},{"code":"EP-021","title":"Y","layer":"business"}]}' \
1696
+ | python3 "$GB" --from-docs "$TMP/docs/sin-campo.md" > /dev/null 2> "$TMP/gb10.err"
1697
+ grep -q "EP-020 tiene sección" "$TMP/gb10.err" && grep -q "EP-021 no aparece como sección" "$TMP/gb10.err" \
1698
+ && echo "OK distingue sección sin campo de épica ausente de los docs" || { echo "FAIL avisos de cobertura de docs"; fail=1; }
1699
+
1700
+ # 4-ter) Las dos banderas juntas son un error explícito (ignorar una en silencio es el
1701
+ # patrón que causó #56).
1702
+ echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/epicas.md" --print-deps "$TMP/docs/epicas.md" > /dev/null 2> "$TMP/gb11.err"
1703
+ [ "$?" = 1 ] && grep -qi "excluyentes" "$TMP/gb11.err" \
1704
+ && echo "OK --from-docs y --print-deps juntas -> rc 1 explícito" || { echo "FAIL banderas excluyentes"; fail=1; }
1705
+
1706
+ # 5) Sin sección de épica reconocible: aviso explícito (no silencio).
1707
+ printf 'texto sin encabezados de epica\n' > "$TMP/docs/vacio.md"
1708
+ echo '{"epics":[{"code":"EP-001","title":"X","layer":"business"}]}' \
1709
+ | python3 "$GB" --from-docs "$TMP/docs/vacio.md" > /dev/null 2> "$TMP/gb9.err"
1710
+ grep -qi "ninguna secci" "$TMP/gb9.err" \
1711
+ && echo "OK avisa cuando los docs no traen secciones de épica" || { echo "FAIL sin aviso de docs sin épicas"; fail=1; }
1712
+
1713
+
1367
1714
  exit $fail
@@ -133,6 +133,16 @@ Espejo de la Fase 0, para proyectos **con UI**. Antes de abrir el primer slice c
133
133
  | 7 · pr | Abrir PR + archivar change en el mismo PR | `opsx:archive`, `opsx:sync` | — | `gitflow.md` |
134
134
  | 8 · release? | Preguntar si correr el Release Gate ahora | usuario (default computado) | — | abajo |
135
135
 
136
+ > **Reporta la fase al runtime en cada transición** (modos `dual`/`runtime`; en `legacy` el
137
+ > comando devuelve rc 3 y no haces nada): `bash .claude/hooks/build/slice-ops.sh phase <to>`
138
+ > **al entrar** en cada fase. Las fases del hub son más finas que las de esta tabla:
139
+ > `dor → change → red → green → refactor → smoke → api → data → dod → pr` — la fase 3 · tdd
140
+ > reporta **tres** (`phase red` al escribir el test rojo, `phase green` al pasarlo,
141
+ > `phase refactor` al refactorizar) y la 5 reporta `api` y luego `data`; la 7 · pr reporta
142
+ > `phase pr` al abrir el PR. El subcomando es idempotente y rellena huecos solo (cadena
143
+ > completa, el orden del hub es estricto); sin estos reportes el slice queda «en
144
+ > construcción» en el hub aunque su PR ya esté mergeado. Ver `references/runtime-protocol.md`.
145
+
136
146
  Los gates `stack`, `security`, `smell`, `ux` y la coherencia triple completa **ya no se cierran
137
147
  aquí**: pertenecen al Release Gate. Las **deps** siguen vigiladas en tiempo real por el hook
138
148
  `stack-guard.sh`; lint/tsc/gitflow por sus hooks.
@@ -35,16 +35,17 @@ acto pasa por `slice-ops.sh` (inner loop) y `release-ops.sh` (outer loop), insta
35
35
  |---|---|---|
36
36
  | Reclamar | `slice-ops.sh claim [--epic EP-XXX]` | Sincroniza el contexto **antes** (regla: no se abre trabajo con contexto viejo) y reporta los sha256 locales de los archivos gobernados. Si la respuesta trae `checkpoint`, **continúa desde él; jamás reinicies**. |
37
37
  | Saber el paso | `slice-ops.sh next-step` | Lo deriva el **cliente** desde la proyección: `resume_hint` → wiring `failing` → sub-slice pendiente → primer gate abierto. |
38
+ | Reportar la fase | `slice-ops.sh phase <to>` | **Al entrar en cada fase del pipeline** (`dor → change → red → green → refactor → smoke → api → data → dod → pr`, orden estricto del hub). Emite la cadena `phase_advanced` que falte desde la fase que el servidor conoce (nada si ya está: idempotente). Sin esto el slice se queda en `dor` server-side y el archivado se rechaza. `archived` no se reporta aquí: lo produce `archive`. |
38
39
  | Sembrar el cableado | `slice-ops.sh wiring seed --file items.json` | Un item por escenario AC y por punto de integración entre capas. Nacen `failing`. |
39
40
  | Cablear un item | `slice-ops.sh wiring update <item_id> passing --kind K --ref HU-XXX --evidence-file f` | `passing` **solo tras prueba real ejecutada**. Sin evidencia el comando avisa: no lo ignores. |
40
41
  | Cerrar un gate | `slice-ops.sh gate <nombre> <pass\|fail\|na> --evidence-file f` | Gates canónicos: `dor coherence_link tdd journey_smoke fidelity api data wiring_verified dod`. Un `na` es legítimo cuando el gate no aplica. |
41
42
  | Dejar bitácora | `slice-ops.sh progress «hito»` | Sustituye a `progress_log[]`: el event stream **es** la bitácora. |
42
43
  | Guardar continuidad | `slice-ops.sh checkpoint --note «dónde me quedé»` | Rama + commit reales. Es lo que permite que otro agente continúe tras una caída. |
43
44
  | Entregar | `slice-ops.sh submit --pr-url U --openspec-change C` | Tras abrir el PR. |
44
- | Archivar | `slice-ops.sh archive --epic EP-XXX --openspec-change C --pr-url U` | Lo dispara `opsx:archive` en el mismo PR. Va por cola (idempotente, protegido de la cota). |
45
+ | Archivar | `slice-ops.sh archive --epic EP-XXX --openspec-change C --pr-url U` | Lo dispara `opsx:archive` en el mismo PR. Va por cola (idempotente, protegido de la cota). **Catch-up automático**: si la fase server-side quedó atrás, emite la cadena `phase_advanced` hasta `pr` antes del archivado y lo avisa (`⚠ … server-side`). |
45
46
  | Hechos de proyecto | `slice-ops.sh fact scaffold-confirmed --by «persona»` · `fact design-source …` · `fact project-kind …` · `fact foundation …` | **Reportan lo que un humano confirmó**; el agente nunca confirma por su cuenta. |
46
47
  | Bloqueo | `slice-ops.sh escalate «razón» --gate G` | Deja el rastro; la decisión (recortar/diferir/desbloquear) es del equipo. |
47
- | Ver el estado | `slice-ops.sh status` | Modo, slice, gates, wiring, contexto, lease y cola pendiente. |
48
+ | Ver el estado | `slice-ops.sh status` | Modo, slice, gates, wiring, contexto, lease, cola pendiente y **rechazados** (`outbox/rejected/`: conteo + razón del más reciente — un rechazo del hub NO se reintenta solo; corrige la causa y re-emite). |
48
49
 
49
50
  ## Reglas duras
50
51
 
@@ -60,6 +61,10 @@ acto pasa por `slice-ops.sh` (inner loop) y `release-ops.sh` (outer loop), insta
60
61
  reconectar y el `client_event_id` evita duplicados.
61
62
  7. **El agente no gobierna.** No cierra releases, no publica contexto, no importa el grafo,
62
63
  no abre/drena/cierra fronts: prepara, propone, reporta — y el humano decide en la consola.
64
+ 8. **La fase se reporta al entrar, no al final.** El hub lleva la fase con `phase_advanced`
65
+ de orden estricto: invoca `phase <to>` en cada transición del pipeline. Si un pipeline
66
+ corrió sin reportarla, `archive` completa la cadena solo (catch-up) — pero la telemetría
67
+ intermedia del hub habrá estado ciega todo el slice.
63
68
 
64
69
  ## Qué queda del fichero (legado)
65
70
 
@@ -95,7 +95,10 @@ Archive a completed change in the experimental workflow.
95
95
 
96
96
  It is queued and idempotent (protected from the outbox cap), so it survives an offline
97
97
  archive; `rc 3` simply means legacy mode (the file protocol applies). Never block the
98
- archive on this step.
98
+ archive on this step. If the pipeline ran without phase reports, the command catches up
99
+ by itself: it emits the missing `phase_advanced` chain up to `pr` before the archive
100
+ event (the hub rejects `slice_archived` on any earlier phase) and prints a `⚠ … server-side`
101
+ notice — informative, not an error.
99
102
 
100
103
  6. **Display summary**
101
104