@trycore/spec-build-harness 0.14.1 → 0.15.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.
@@ -31,11 +31,20 @@ source "$ROOT/hooks/build/lib/runtime-ops.sh" 2>/dev/null || { echo "FAIL no exi
31
31
  [ "$(ops_path_front_integration f-1 ak-1)" = "/fronts/f-1/members/ak-1/integration" ] && echo "OK ops_path_front_integration" || { echo "FAIL ops_path_front_integration"; fail=1; }
32
32
 
33
33
  # [#62] Tabla de rutas de la propuesta de épica: un solo sitio donde se escriben rutas.
34
- [ "$(ops_path_epic_proposals p-1)" = "/projects/p-1/epic-proposals" ] \
34
+ # [#70] El carril del AGENTE lleva `/agent/`: `/projects/{id}/epic-proposals` es la vista de
35
+ # consola (GET, JWT de usuario) y devuelve 405 a un POST — el drift que rompió el piloto.
36
+ [ "$(ops_path_epic_proposals p-1)" = "/projects/p-1/agent/epic-proposals" ] \
35
37
  && echo "OK ops_path_epic_proposals" || { echo "FAIL ops_path_epic_proposals"; fail=1; }
36
- [ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/epic-proposals/prop-7" ] \
38
+ [ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/agent/epic-proposals/prop-7" ] \
37
39
  && echo "OK ops_path_epic_proposal" || { echo "FAIL ops_path_epic_proposal"; fail=1; }
38
40
 
41
+ # [EP-OR-17] Re-sync del grafo: mismo carril `/agent/`. Sin él se cae en la superficie HUMANA
42
+ # (sesión + PDP), que a un bearer de agente le responde 403 auditado.
43
+ [ "$(ops_path_graph_sync_proposals p-1)" = "/projects/p-1/agent/graph-sync-proposals" ] \
44
+ && echo "OK ops_path_graph_sync_proposals" || { echo "FAIL ops_path_graph_sync_proposals"; fail=1; }
45
+ [ "$(ops_path_graph_sync_proposal p-1 gs-7)" = "/projects/p-1/agent/graph-sync-proposals/gs-7" ] \
46
+ && echo "OK ops_path_graph_sync_proposal" || { echo "FAIL ops_path_graph_sync_proposal"; fail=1; }
47
+
39
48
  # [#62] Ledger: upsert por client_event_id, sin duplicar, conservando lo no mencionado.
40
49
  rm -f "$(ops_epic_ledger_path)"
41
50
  ops_epic_ledger_upsert '{"client_event_id":"cid-1","status":"QUEUED","draft":{"title":"Notificaciones"}}'
@@ -142,7 +151,7 @@ fi
142
151
  rm -f "$PROJ/.claude/state/outbox/"*.json
143
152
 
144
153
  # --- ops_mirror: sólo tiene sentido con project_id; nunca lanza sin él ---
145
- ops_mirror gate_reported "$TMP/payload.json"; rc=$?
154
+ ops_mirror gate_verdict "$TMP/payload.json" EP-003; rc=$?
146
155
  [ "$rc" -ne 0 ] && echo "OK ops_mirror sin credenciales -> rc != 0" || { echo "FAIL ops_mirror sin credenciales"; fail=1; }
147
156
 
148
157
  # ══════════════ slice-ops.sh: mode / claim / next-step [Task 2] ══════════════
@@ -181,6 +190,56 @@ STUBS="$STUBS $!"
181
190
  sleep 0.3
182
191
  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\"}"
183
192
 
193
+ # ── [EP-OR-17] Ledger y cid del re-sync del grafo ────────────────────────────────────
194
+ # Va aquí, y no arriba con el ledger de épicas, porque `ops_graph_sync_cid` necesita la
195
+ # `agent_key` de las credenciales que la línea anterior acaba de fijar.
196
+ rm -f "$(ops_graph_sync_ledger_path)"
197
+ ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:0","status":"QUEUED","epics":41}'
198
+ ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:0","status":"PROPOSED","proposal_id":"gs-7"}'
199
+ ops_graph_sync_ledger_read | python3 -c "
200
+ import json,sys
201
+ ps=json.load(sys.stdin)['proposals']
202
+ e=ps[0]
203
+ print('SI' if len(ps)==1 and e['status']=='PROPOSED' and e['proposal_id']=='gs-7' and e['epics']==41 else 'NO')" \
204
+ | grep -q SI && echo "OK el ledger de graph-sync hace upsert sin perder campos" \
205
+ || { echo "FAIL upsert del ledger de graph-sync"; fail=1; }
206
+ case "$(ops_graph_sync_ledger_path)" in
207
+ */.claude/state/graph-sync-proposals.json) echo "OK el ledger de graph-sync vive en su propio fichero" ;;
208
+ *) echo "FAIL ruta del ledger de graph-sync"; fail=1 ;;
209
+ esac
210
+
211
+ # El cid es DETERMINISTA y avanza con el ledger: el reintento reusa el mismo, una propuesta
212
+ # nueva toma el siguiente.
213
+ rm -f "$(ops_graph_sync_ledger_path)"
214
+ cid_a="$(ops_graph_sync_cid)"; cid_b="$(ops_graph_sync_cid)"
215
+ [ "$cid_a" = "$cid_b" ] && echo "OK ops_graph_sync_cid es estable mientras no se registre nada" \
216
+ || { echo "FAIL cid inestable ($cid_a / $cid_b)"; fail=1; }
217
+ [ "$cid_a" = "ak-1:graph-sync:1" ] && echo "OK el cid sigue el patrón <agent_key>:graph-sync:<n>" \
218
+ || { echo "FAIL patrón del cid ($cid_a)"; fail=1; }
219
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":\"$cid_a\",\"status\":\"PROPOSED\"}"
220
+ [ "$(ops_graph_sync_cid)" = "ak-1:graph-sync:2" ] \
221
+ && echo "OK el cid avanza cuando el ledger registra una propuesta" \
222
+ || { echo "FAIL el cid no avanza ($(ops_graph_sync_cid))"; fail=1; }
223
+ [ "${#cid_a}" -le 64 ] && echo "OK el cid cabe en los 64 caracteres del contrato" \
224
+ || { echo "FAIL cid demasiado largo (${#cid_a})"; fail=1; }
225
+ # Una agent_key larga no puede desbordar el contrato: se sustituye por su sha256 truncado.
226
+ ak_prev="$(runtime_field agent_key)"
227
+ runtime_credentials_merge "{\"agent_key\":\"$(python3 -c 'print("a"*120)')\"}"
228
+ cid_l="$(ops_graph_sync_cid)"
229
+ [ "${#cid_l}" -le 64 ] && [ -n "$cid_l" ] \
230
+ && echo "OK una agent_key larga no desborda el cid (${#cid_l})" \
231
+ || { echo "FAIL cid desbordado con agent_key larga (${#cid_l})"; fail=1; }
232
+ runtime_credentials_merge "{\"agent_key\":\"$ak_prev\"}"
233
+ rm -f "$(ops_graph_sync_ledger_path)"
234
+
235
+ # El ledger de épicas no se toca: misma conducta tras extraer la base común.
236
+ rm -f "$(ops_epic_ledger_path)"
237
+ ops_epic_ledger_upsert '{"client_event_id":"cid-9","status":"QUEUED"}'
238
+ ops_epic_ledger_read | grep -q '"cid-9"' \
239
+ && echo "OK el ledger de épicas sobrevive a la extracción de la base común" \
240
+ || { echo "FAIL regresión del ledger de épicas"; fail=1; }
241
+ rm -f "$(ops_epic_ledger_path)"
242
+
184
243
  # (#39) `claim --epic` NO existe en el protocolo del hub: `TasksNextIn` no declara
185
244
  # epic_code (pydantic lo ignoraba y la cola repartía OTRA épica con claim "exitoso").
186
245
  # Debe fallar EXPLÍCITO antes de tocar la red.
@@ -925,16 +984,18 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
925
984
  [ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase en dual -> rc 0 sin encolar" || { echo "FAIL phase dual (rc=$rc n=$n)"; fail=1; }
926
985
  [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
927
986
  import json,sys
987
+ # [EP-OR-17] El shape es MirrorTransitionsIn: la épica va en el SOBRE (`epic_code`), no como
988
+ # `slice_ref` dentro del payload — los schemas del catálogo son extra="forbid".
928
989
  tos=[]
929
990
  for line in open(sys.argv[1]):
930
991
  c=json.loads(line)
931
992
  if c["path"]=="/projects/p-1/mirror/transitions":
932
- b=json.loads(c["body"])
933
- if b.get("kind")=="phase_advanced":
934
- tos.append((b["payload"].get("to"), b["payload"].get("slice_ref",{}).get("epic_code")))
993
+ for t in json.loads(c["body"])["transitions"]:
994
+ if t["event_type"]=="phase_advanced":
995
+ tos.append((t["payload"].get("to"), t["epic_code"]))
935
996
  print("SI" if tos==[("green","EP-009")] else "NO:"+repr(tos))
936
997
  PYX
937
- )" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con slice_ref" \
998
+ )" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con el epic_code en el sobre" \
938
999
  || { echo "FAIL espejo de phase en dual"; fail=1; }
939
1000
 
940
1001
  # dual: archive también hace catch-up por el espejo (cadena red→…→pr antes del archivado).
@@ -948,12 +1009,14 @@ kinds=[]
948
1009
  for line in open(sys.argv[1]):
949
1010
  c=json.loads(line)
950
1011
  if c["path"]=="/projects/p-1/mirror/transitions":
951
- b=json.loads(c["body"])
952
- kinds.append((b.get("kind"), b["payload"].get("to")))
953
- esperado=[("phase_advanced",t) for t in ["green","refactor","smoke","api","data","dod","pr"]]+[("slice_archived",None)]
1012
+ for t in json.loads(c["body"])["transitions"]:
1013
+ kinds.append((t["event_type"], t["payload"].get("to")))
1014
+ # [EP-OR-17] `slice_archived` NO está en TIPOS_DE_ESPEJO del hub: la cadena de fases sí se
1015
+ # espeja, y el archivado se corta en local (rc 8) en vez de viajar para volver rechazado.
1016
+ esperado=[("phase_advanced",t) for t in ["green","refactor","smoke","api","data","dod","pr"]]
954
1017
  print("SI" if kinds==esperado else "NO:"+repr(kinds))
955
1018
  PYX
956
- )" = SI ] && echo "OK archive en dual espeja la cadena hasta pr y luego slice_archived" \
1019
+ )" = SI ] && echo "OK archive en dual espeja la cadena de fases y no el slice_archived no espejable" \
957
1020
  || { echo "FAIL cadena del archive dual"; fail=1; }
958
1021
 
959
1022
  # Restaura la ruta y la caché al estado que esperan los bloques siguientes (sl-7, fase tdd).
@@ -980,12 +1043,16 @@ for line in open(sys.argv[1]):
980
1043
  c=json.loads(line)
981
1044
  if c["path"]=="/projects/p-1/mirror/transitions":
982
1045
  b=json.loads(c["body"])
983
- # [#44] el espejo también usa el nombre del catálogo (TIPOS_DE_ESPEJO del hub
984
- # enumera gate_verdict, no verdict_reported).
985
- ok = b["kind"]=="gate_verdict" and b["origin"]=="mirror" and b["payload"]["slice_ref"]["epic_code"]=="EP-009"
1046
+ t=b["transitions"][0]
1047
+ # [#44] el espejo usa el nombre del catálogo (gate_verdict, no verdict_reported).
1048
+ # [EP-OR-17] La identidad local viaja en el SOBRE (`epic_code`); el `origin` ya no se
1049
+ # manda — la marca de origen-espejo la estampa el SERVIDOR, y lo que dijera el cliente
1050
+ # no elegiría el origen de todos modos.
1051
+ ok = (list(b.keys())==["transitions"] and t["event_type"]=="gate_verdict"
1052
+ and t["epic_code"]=="EP-009" and "slice_ref" not in t["payload"])
986
1053
  print("SI" if ok else "NO")
987
1054
  PYX
988
- )" = SI ] && echo "OK dual espeja con la identidad fichero-primaria" || { echo "FAIL espejo dual"; fail=1; }
1055
+ )" = SI ] && echo "OK dual espeja con la identidad fichero-primaria en el sobre" || { echo "FAIL espejo dual"; fail=1; }
989
1056
  [ ! -s "$PROJ/.claude/state/outbox" ] || true
990
1057
  grep -q '"path": "/slices/' "$TMP/calls-ga.jsonl" && { echo "FAIL dual llamó a la superficie primaria"; fail=1; } || echo "OK dual no llama a /slices (el fichero decide)"
991
1058
 
@@ -997,12 +1064,15 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
997
1064
 
998
1065
  # ══════════════ hooks/build/dual-compare.sh [Task E] ══════════════
999
1066
  # build-state.json de arriba sigue en pie: EP-009, phase tdd, gates {dor:true}.
1000
- # En dual, escalate NO encola: espeja por POST /projects/{id}/mirror/transitions (el
1001
- # fichero es primario) igual que el resto de cmd_* en dual (ver el bloque de arriba).
1067
+ # En dual, escalate NO encola: intenta el espejo (el fichero es primario).
1068
+ # [EP-OR-17] `slice_escalated` existe en el catálogo del hub pero NO es espejable
1069
+ # (`TIPOS_DE_ESPEJO` solo cubre apertura, fases, gates, wiring, checkpoints, handoff y notas de
1070
+ # progreso). Antes la escalada viajaba para volver rechazada; ahora se corta en local. La
1071
+ # discrepancia sigue siendo VISIBLE donde el humano la lee —el stderr del hook Stop— y el hueco
1072
+ # de registro en el hub queda documentado como pendiente hub-side, no disimulado aquí.
1002
1073
  DC="$ROOT/hooks/build/dual-compare.sh"
1003
1074
  mirror_escalated() {
1004
- # SI/NO: ¿calls-ga.jsonl tiene un POST a mirror/transitions con kind=slice_escalated?
1005
- # [#44] la escalada usa el nombre del catálogo v2 (slice_escalated) también en el espejo.
1075
+ # SI/NO: ¿viajó al espejo un `slice_escalated`? Debe ser NO: el hub no lo acepta.
1006
1076
  python3 - "$TMP/calls-ga.jsonl" <<'PYX'
1007
1077
  import json,sys
1008
1078
  found=False
@@ -1012,9 +1082,9 @@ for line in open(sys.argv[1]):
1012
1082
  except Exception:
1013
1083
  continue
1014
1084
  if c.get("path")=="/projects/p-1/mirror/transitions":
1015
- b=json.loads(c["body"])
1016
- if b.get("kind")=="slice_escalated":
1017
- found=True
1085
+ for t in json.loads(c["body"]).get("transitions") or []:
1086
+ if t.get("event_type")=="slice_escalated":
1087
+ found=True
1018
1088
  print("SI" if found else "NO")
1019
1089
  PYX
1020
1090
  }
@@ -1051,9 +1121,10 @@ json.dump(d,open(p,"w"))
1051
1121
  PYX
1052
1122
  : > "$TMP/calls-ga.jsonl"
1053
1123
  rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
1054
- [ "$rc" = 0 ] && [ "$(mirror_escalated)" = SI ] && echo "$err" | grep -qi "discrepancia" \
1124
+ [ "$rc" = 0 ] && [ "$(mirror_escalated)" = NO ] && echo "$err" | grep -qi "discrepancia" \
1055
1125
  && echo "$err" | grep -q "EP-009" && echo "$err" | grep -q "EP-999" \
1056
- && echo "OK dual-compare con épica distinta escala con el detalle de ambos lados" || { echo "FAIL dual-compare épica distinta (rc=$rc err=$err)"; fail=1; }
1126
+ && echo "OK dual-compare con épica distinta avisa con el detalle de ambos lados, sin mandar al espejo un tipo que el hub no acepta" \
1127
+ || { echo "FAIL dual-compare épica distinta (rc=$rc err=$err)"; fail=1; }
1057
1128
 
1058
1129
  # dual, gate DISTINTO (mismo epic/fase): también cuenta como discrepancia.
1059
1130
  python3 - "$TMP/routes-ga.json" <<'PYX'
@@ -1063,8 +1134,10 @@ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9",
1063
1134
  json.dump(d,open(p,"w"))
1064
1135
  PYX
1065
1136
  : > "$TMP/calls-ga.jsonl"
1066
- rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
1067
- [ "$(mirror_escalated)" = SI ] && echo "OK dual-compare detecta gate distinto (dor: true vs false)" || { echo "FAIL dual-compare gate distinto"; fail=1; }
1137
+ rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
1138
+ echo "$err" | grep -qi "discrepancia" && [ "$(mirror_escalated)" = NO ] \
1139
+ && echo "OK dual-compare detecta gate distinto (dor: true vs false)" \
1140
+ || { echo "FAIL dual-compare gate distinto (rc=$rc err=$err)"; fail=1; }
1068
1141
 
1069
1142
  # dual, runtime SIN slice activo (el fichero sí tiene): también es discrepancia.
1070
1143
  python3 - "$TMP/routes-ga.json" <<'PYX'
@@ -1074,8 +1147,10 @@ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":Tr
1074
1147
  json.dump(d,open(p,"w"))
1075
1148
  PYX
1076
1149
  : > "$TMP/calls-ga.jsonl"
1077
- rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
1078
- [ "$(mirror_escalated)" = SI ] && echo "OK dual-compare detecta slice ausente en el runtime" || { echo "FAIL dual-compare slice ausente en runtime"; fail=1; }
1150
+ rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
1151
+ echo "$err" | grep -qi "discrepancia" && [ "$(mirror_escalated)" = NO ] \
1152
+ && echo "OK dual-compare detecta slice ausente en el runtime" \
1153
+ || { echo "FAIL dual-compare slice ausente en runtime (rc=$rc err=$err)"; fail=1; }
1079
1154
 
1080
1155
  # fail-open: espejo caído (runtime inalcanzable) -> nunca lanza, nunca aborta (rc 0 siempre).
1081
1156
  python3 - "$TMP/routes-ga.json" <<'PYX'
@@ -1618,9 +1693,13 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
1618
1693
  # ══════════════ slice-ops.sh: propose-epic / epic-status / epic-writeback [#62] ══════════════
1619
1694
  # Stub PROPIO (puerto, routes-ep.json y calls-ep.jsonl): routes-cl.json/STUB_CL ya no existen
1620
1695
  # en este punto del fichero y mutarlos rompería aserciones aguas arriba.
1696
+ # [#70] Las rutas y los cuerpos de este bloque son los del CONTRATO DEL AGENTE del hub
1697
+ # (`contrato-agente-openapi.json`, EP-OR-14/EP-OR-15), no los del borrador de hub#113:
1698
+ # carril `/agent/…`, `layer` en MAYÚSCULAS (FOUNDATIONAL|BUSINESS), historias con
1699
+ # `description`+`acceptance[]`, y respuesta `{id, status, assigned_code, reject_reason}`.
1621
1700
  PORT_EP="$(freeport)"
1622
1701
  cat > "$TMP/routes-ep.json" <<JSON
1623
- {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-77", "status": "PROPOSED"}}}
1702
+ {"POST /projects/p-1/agent/epic-proposals": {"status": 201, "body": {"id": "prop-77", "project_id": "p-1", "title": "Notificaciones en tiempo real", "objective": "que el usuario vea los avisos sin recargar", "layer": "BUSINESS", "stories": [], "files_scope": [], "depends_on": [], "status": "PROPOSED", "assigned_code": null, "reject_reason": null}}}
1624
1703
  JSON
1625
1704
  : > "$TMP/calls-ep.jsonl"
1626
1705
  python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_EP" "$TMP/routes-ep.json" "$TMP/calls-ep.jsonl" &
@@ -1634,7 +1713,8 @@ cat > "$TMP/draft.json" <<'JSON'
1634
1713
  {"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1635
1714
  "layer":"business","files_scope":["src/notifications/**"],"depends_on":["EP-012"],
1636
1715
  "epic_code":"EP-999",
1637
- "stories":[{"title":"ver el aviso","acceptance_criteria":"Dado Cuando Entonces "}]}
1716
+ "stories":[{"title":"ver el aviso","description":"Como usuario quiero ver el aviso sin recargar",
1717
+ "acceptance_criteria":"Dado un aviso nuevo Cuando llega Entonces se ve. Dado un aviso leído Cuando entro Entonces no se repite"}]}
1638
1718
  JSON
1639
1719
 
1640
1720
  # legacy y dual: rc 3 sin efectos (ni cola, ni red).
@@ -1659,22 +1739,59 @@ out="$(bash "$SO" propose-epic --file "$TMP/draft.json" 2>&1)"; rc=$?
1659
1739
  && echo "OK propose-epic envia y devuelve el identificador" \
1660
1740
  || { echo "FAIL propose-epic online (rc $rc): $out"; fail=1; }
1661
1741
 
1662
- # El EP-XXX del borrador se IGNORA (la identidad la asigna el hub, hub#113) — ignorar, no rechazar.
1742
+ # [#70] El cuerpo enviado valida contra `EpicProposalIn` del contrato del agente: `layer` en
1743
+ # MAYÚSCULAS, historias con `description`+`acceptance[]` y NUNCA `acceptance_criteria` (el
1744
+ # hub lo descarta en el borde con extra="ignore": la consola mostraba las HU sin criterios).
1663
1745
  body="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
1664
1746
  import json,sys
1665
1747
  for l in open(sys.argv[1]):
1666
1748
  d=json.loads(l)
1667
- if d["path"]=="/projects/p-1/epic-proposals":
1668
- b=json.loads(d["body"])
1669
- print("SI" if "epic_code" not in b and b.get("title","").startswith("Notificaciones") else "NO")
1749
+ if d["path"]=="/projects/p-1/agent/epic-proposals":
1750
+ b=json.loads(d["body"]); errores=[]
1751
+ if b.get("layer")!="BUSINESS": errores.append("layer=%r" % b.get("layer"))
1752
+ if "epic_code" in b: errores.append("viajo el epic_code del borrador")
1753
+ if not b.get("title","").startswith("Notificaciones"): errores.append("title=%r" % b.get("title"))
1754
+ s=(b.get("stories") or [{}])[0]
1755
+ if "acceptance_criteria" in s: errores.append("la historia lleva acceptance_criteria")
1756
+ if set(s)-{"title","description","acceptance","code","acceptance_ref"}:
1757
+ errores.append("campos fuera del contrato: %s" % sorted(set(s)-{"title","description","acceptance","code","acceptance_ref"}))
1758
+ if s.get("description")!="Como usuario quiero ver el aviso sin recargar":
1759
+ errores.append("description=%r" % s.get("description"))
1760
+ acc=s.get("acceptance")
1761
+ if not isinstance(acc,list) or len(acc)!=2: errores.append("acceptance=%r" % acc)
1762
+ elif not acc[0].startswith("Dado un aviso nuevo") or not acc[1].startswith("Dado un aviso leído"):
1763
+ errores.append("escenarios mal partidos: %r" % acc)
1764
+ print("SI" if not errores else "NO: "+" | ".join(errores))
1670
1765
  break
1671
1766
  PY
1672
1767
  )"
1673
- [ "$body" = SI ] && echo "OK propose-epic ignora el EP-XXX del borrador" \
1674
- || { echo "FAIL el EP-XXX viajo al hub ($body)"; fail=1; }
1768
+ [ "$body" = SI ] && echo "OK el cuerpo de propose-epic valida contra el contrato del agente" \
1769
+ || { echo "FAIL cuerpo de propose-epic ($body)"; fail=1; }
1675
1770
  echo "$out" | grep -qi 'ignorado' && echo "OK propose-epic avisa de que ignoro el codigo" \
1676
1771
  || { echo "FAIL sin aviso del codigo ignorado"; fail=1; }
1677
1772
 
1773
+ # [#70] `technical` no existe en el enum del hub (FOUNDATIONAL|BUSINESS): el 422 que devolvía
1774
+ # se evita mapeándolo a BUSINESS en el borde, no rechazando el borrador del agente.
1775
+ cat > "$TMP/draft-tech.json" <<'JSON'
1776
+ {"title":"Observabilidad","objective":"ver qué pasa en produccion","layer":"technical",
1777
+ "stories":[{"title":"ver las trazas"}]}
1778
+ JSON
1779
+ : > "$TMP/calls-ep.jsonl"
1780
+ bash "$SO" propose-epic --file "$TMP/draft-tech.json" >/dev/null 2>&1
1781
+ tech="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
1782
+ import json,sys
1783
+ for l in open(sys.argv[1]):
1784
+ d=json.loads(l)
1785
+ if d["path"]=="/projects/p-1/agent/epic-proposals":
1786
+ b=json.loads(d["body"])
1787
+ s=(b.get("stories") or [{}])[0]
1788
+ print("SI" if b.get("layer")=="BUSINESS" and "acceptance" not in s else "NO: layer=%r story=%r" % (b.get("layer"), s))
1789
+ break
1790
+ PY
1791
+ )"
1792
+ [ "$tech" = SI ] && echo "OK layer technical viaja como BUSINESS y una historia sin AC no inventa acceptance" \
1793
+ || { echo "FAIL mapeo de layer technical ($tech)"; fail=1; }
1794
+
1678
1795
  # El ledger recuerda la propuesta con su borrador.
1679
1796
  ops_epic_ledger_read | python3 -c "
1680
1797
  import json,sys
@@ -1710,14 +1827,18 @@ out="$(bash "$SO" propose-epic --file "$TMP/malo.json" 2>&1)"
1710
1827
  || { echo "FAIL validacion local del borrador: $out"; fail=1; }
1711
1828
 
1712
1829
  # --- [#62] epic-status ---
1830
+ # [#70] La respuesta es `EpicProposalOut` del contrato: el código aprobado viaja en
1831
+ # `assigned_code` y la épica en la RAÍZ (no anidada en `epic`).
1713
1832
  python3 - "$TMP/routes-ep.json" <<'PY'
1714
1833
  import json,sys
1715
1834
  p=sys.argv[1]; d=json.load(open(p))
1716
- d["GET /projects/p-1/epic-proposals/prop-77"]={"status":200,"body":{
1717
- "proposal_id":"prop-77","status":"APPROVED","epic_code":"EP-045",
1718
- "epic":{"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1719
- "layer":"business","depends_on":["EP-012"],
1720
- "stories":[{"code":"HU-310","title":"ver el aviso"}]}}}
1835
+ d["GET /projects/p-1/agent/epic-proposals/prop-77"]={"status":200,"body":{
1836
+ "id":"prop-77","project_id":"p-1","status":"APPROVED","assigned_code":"EP-045",
1837
+ "title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1838
+ "layer":"BUSINESS","depends_on":["EP-012"],"files_scope":["src/notifications/**"],
1839
+ "stories":[{"code":"HU-310","title":"ver el aviso","description":"Como usuario…",
1840
+ "acceptance":["Dado un aviso nuevo Cuando llega Entonces se ve"],"acceptance_ref":None},],
1841
+ "reject_reason":None}}
1721
1842
  json.dump(d,open(p,"w"))
1722
1843
  PY
1723
1844
  TRYCORE_RUNTIME_MODE=legacy bash "$SO" epic-status >/dev/null 2>&1
@@ -1733,20 +1854,38 @@ out="$(bash "$SO" epic-status 2>&1)"; rc=$?
1733
1854
  || { echo "FAIL epic-status (rc $rc): $out"; fail=1; }
1734
1855
  ops_epic_ledger_read | python3 -c "
1735
1856
  import json,sys
1736
- p=json.load(sys.stdin)['proposals'][0]
1737
- print('SI' if p['status']=='APPROVED' and p['epic_code']=='EP-045' and p['epic']['stories'][0]['code']=='HU-310' else 'NO')" \
1857
+ ps=json.load(sys.stdin)['proposals']
1858
+ p=[q for q in ps if q.get('proposal_id')=='prop-77'][0]
1859
+ print('SI' if p['status']=='APPROVED' and p['epic_code']=='EP-045' and p['epic']['stories'][0]['code']=='HU-310' and p['epic']['title'].startswith('Notificaciones') else 'NO')" \
1738
1860
  | grep -q SI && echo "OK epic-status guarda el codigo y el contenido aprobado" \
1739
1861
  || { echo "FAIL el ledger no recogio la aprobacion"; fail=1; }
1740
1862
 
1863
+ # [#70] El rechazo humano viaja en `reject_reason` (EP-OR-15): sin leerlo, la terminal decía
1864
+ # "sin motivo registrado" y el usuario no sabía qué corregir.
1865
+ python3 - "$TMP/routes-ep.json" <<'PY'
1866
+ import json,sys
1867
+ p=sys.argv[1]; d=json.load(open(p))
1868
+ d["GET /projects/p-1/agent/epic-proposals/prop-88"]={"status":200,"body":{
1869
+ "id":"prop-88","project_id":"p-1","status":"REJECTED","assigned_code":None,
1870
+ "title":"Otra","objective":"otra cosa","layer":"BUSINESS","depends_on":[],"files_scope":[],
1871
+ "stories":[],"reject_reason":"se solapa con EP-012"}}
1872
+ json.dump(d,open(p,"w"))
1873
+ PY
1874
+ ops_epic_ledger_upsert '{"client_event_id":"cid-r","proposal_id":"prop-88","status":"PROPOSED"}'
1875
+ out="$(bash "$SO" epic-status --id prop-88 2>&1)"; rc=$?
1876
+ [ $rc -eq 0 ] && echo "$out" | grep -q 'se solapa con EP-012' \
1877
+ && echo "OK epic-status muestra el motivo del rechazo humano" \
1878
+ || { echo "FAIL motivo del rechazo (rc $rc): $out"; fail=1; }
1879
+
1741
1880
  # Un hub sin soporte (404) se dice con esas palabras y NO rompe nada.
1742
1881
  python3 - "$TMP/routes-ep.json" <<'PY'
1743
1882
  import json,sys
1744
1883
  p=sys.argv[1]; d=json.load(open(p))
1745
- d.pop("GET /projects/p-1/epic-proposals/prop-77",None)
1884
+ d.pop("GET /projects/p-1/agent/epic-proposals/prop-77",None)
1746
1885
  json.dump(d,open(p,"w"))
1747
1886
  PY
1748
1887
  ops_epic_ledger_upsert '{"client_event_id":"cid-x","proposal_id":"prop-77","status":"PROPOSED"}'
1749
- out="$(bash "$SO" epic-status 2>&1)"; rc=$?
1888
+ out="$(bash "$SO" epic-status --id prop-77 2>&1)"; rc=$?
1750
1889
  [ $rc -eq 0 ] && echo "$out" | grep -qi 'sin soporte' \
1751
1890
  && echo "OK epic-status nombra la falta de soporte del hub" \
1752
1891
  || { echo "FAIL epic-status con 404 (rc $rc): $out"; fail=1; }
@@ -1933,6 +2072,317 @@ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
1933
2072
  [ $rc -eq 4 ] && echo "OK epic-writeback rc 4 sin fichero de epicas" \
1934
2073
  || { echo "FAIL epic-writeback sin fichero (rc $rc): $out"; fail=1; }
1935
2074
 
2075
+ # ══════════════ slice-ops.sh: graph-sync / graph-sync-status [EP-OR-17] ══════════════
2076
+ # Stub PROPIO (puerto, routes-gs.json y calls-gs.jsonl): mutar los de arriba rompería
2077
+ # aserciones aguas arriba, y este bloque reescribe la ruta del POST varias veces.
2078
+ PORT_GS="$(freeport)"
2079
+ cat > "$TMP/routes-gs.json" <<'JSON'
2080
+ {"POST /projects/p-1/agent/graph-sync-proposals": {"status": 201, "body": {"id": "gs-7", "project_id": "p-1", "status": "PROPOSED", "graph_version": 4, "bundle": {"epics": []}, "delta": {"epics_added": [{"code": "EP-002", "title": "Cobros", "layer": "BUSINESS", "stories": ["HU-011"]}], "epics_changed": [], "stories_added": [], "stories_changed": []}, "proposed_by": "ak-1", "proposed_at": "2026-09-07T10:00:00Z"}},
2081
+ "GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": null, "nudges": [], "context": {"manifest_hash": "sha256:v1", "version": 4, "graph_version": 4}}},
2082
+ "POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
2083
+ JSON
2084
+ : > "$TMP/calls-gs.jsonl"
2085
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GS" "$TMP/routes-gs.json" "$TMP/calls-gs.jsonl" &
2086
+ STUB_GS=$!
2087
+ STUBS="$STUBS $STUB_GS"
2088
+ sleep 0.3
2089
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GS\",\"project_token\":\"tok-gs\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\"}"
2090
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v1","version":4,"graph_version":4}}'
2091
+
2092
+ cat > "$TMP/gs-epics.json" <<'JSON'
2093
+ {"epics":[{"code":"EP-001","title":"Cimiento","layer":"foundational","stories":[]},
2094
+ {"code":"EP-002","title":"Cobros","layer":"business","depends_on":["EP-001"],
2095
+ "stories":[{"id":"HU-011","title":"Registrar cobro"}]}]}
2096
+ JSON
2097
+
2098
+ # legacy y dual: el grafo vive en los documentos y entra al hub por el import de admin.
2099
+ rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
2100
+ [ "$rc" = 3 ] && echo "OK graph-sync en legacy -> rc 3" || { echo "FAIL graph-sync legacy rc ($rc): $out"; fail=1; }
2101
+
2102
+ # --dry-run: imprime el bundle proyectado y NO abre socket. Sirve en cualquier modo.
2103
+ : > "$TMP/calls-gs.jsonl"
2104
+ rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" --dry-run 2>/dev/null)" || rc=$?
2105
+ [ "$rc" = 0 ] && echo "OK graph-sync --dry-run -> rc 0" || { echo "FAIL dry-run rc ($rc)"; fail=1; }
2106
+ printf '%s' "$out" | python3 -c "
2107
+ import json,sys
2108
+ d=json.load(sys.stdin)
2109
+ print('SI' if list(d.keys())==['epics'] and len(d['epics'])==2 else 'NO')" | grep -q SI \
2110
+ && echo "OK --dry-run enseña el bundle proyectado" || { echo "FAIL dry-run sin bundle ($out)"; fail=1; }
2111
+ grep -q 'graph-sync-proposals' "$TMP/calls-gs.jsonl" && { echo "FAIL --dry-run tocó la red"; fail=1; } \
2112
+ || echo "OK --dry-run no abre socket"
2113
+
2114
+ # Bundle que no cumple el contrato: se corta en LOCAL, sin abrir socket.
2115
+ echo '{"epics":[{"code":"EP-009","title":"Sin capa"}]}' > "$TMP/gs-malo.json"
2116
+ : > "$TMP/calls-gs.jsonl"
2117
+ rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-malo.json" 2>&1)" || rc=$?
2118
+ [ "$rc" = 2 ] && echo "OK bundle inválido -> rc 2 (uso)" || { echo "FAIL bundle inválido rc ($rc): $out"; fail=1; }
2119
+ grep -q 'graph-sync-proposals' "$TMP/calls-gs.jsonl" && { echo "FAIL bundle inválido tocó la red"; fail=1; } \
2120
+ || echo "OK un bundle inválido no abre socket"
2121
+
2122
+ # 201: PROPOSED en el ledger con el id del hub; el cuerpo cumple GraphSyncProposeIn.
2123
+ rm -f "$(ops_graph_sync_ledger_path)"
2124
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
2125
+ : > "$TMP/calls-gs.jsonl"
2126
+ rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
2127
+ [ "$rc" = 0 ] && echo "OK graph-sync 201 -> rc 0" || { echo "FAIL graph-sync 201 rc ($rc): $out"; fail=1; }
2128
+ echo "$out" | grep -q "gs-7" && echo "OK graph-sync enseña el id de la propuesta" \
2129
+ || { echo "FAIL id ausente ($out)"; fail=1; }
2130
+ echo "$out" | grep -qi "aprueb" && echo "OK graph-sync dice que falta la aprobación humana" \
2131
+ || { echo "FAIL sin mención de la aprobación ($out)"; fail=1; }
2132
+ echo "$out" | grep -q "EP-002" && echo "OK graph-sync resume el delta que devuelve el hub" \
2133
+ || { echo "FAIL delta sin resumir ($out)"; fail=1; }
2134
+ python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
2135
+ && echo "OK el cuerpo enviado cumple GraphSyncProposeIn" || { echo "FAIL cuerpo de graph-sync"; fail=1; }
2136
+ import json,sys
2137
+ env=None
2138
+ for line in open(sys.argv[1]):
2139
+ c=json.loads(line)
2140
+ if c["path"].endswith("/agent/graph-sync-proposals") and c["method"]=="POST":
2141
+ env=json.loads(c["body"])
2142
+ ok = (env is not None
2143
+ and set(env.keys()) <= {"bundle","client_event_id","graph_version"}
2144
+ and list(env["bundle"].keys())==["epics"]
2145
+ and len(env["bundle"]["epics"])==2
2146
+ and env["bundle"]["epics"][1]["layer"]=="BUSINESS"
2147
+ and env["client_event_id"].endswith(":graph-sync:1")
2148
+ and env["graph_version"]==4)
2149
+ print("SI" if ok else "NO %s" % json.dumps(env))
2150
+ PYX
2151
+ ops_graph_sync_ledger_read | python3 -c "
2152
+ import json,sys
2153
+ ps=json.load(sys.stdin)['proposals']
2154
+ print('SI' if len(ps)==1 and ps[0]['status']=='PROPOSED' and ps[0]['proposal_id']=='gs-7' else 'NO')" \
2155
+ | grep -q SI && echo "OK el ledger registra la propuesta PROPOSED" \
2156
+ || { echo "FAIL ledger tras 201"; fail=1; }
2157
+
2158
+ # 200 empty_delta: el grafo ya está al día. No es un error y NO deja entrada viva en el ledger.
2159
+ python3 - "$TMP/routes-gs.json" <<'PYX'
2160
+ import json,sys
2161
+ d=json.load(open(sys.argv[1]))
2162
+ d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":200,"body":{"empty_delta":True,"message":"El bundle coincide con el grafo vigente: no hay cambios que proponer"}}
2163
+ json.dump(d,open(sys.argv[1],"w"))
2164
+ PYX
2165
+ rm -f "$(ops_graph_sync_ledger_path)"
2166
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
2167
+ rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
2168
+ [ "$rc" = 0 ] && echo "OK graph-sync empty_delta -> rc 0" || { echo "FAIL empty_delta rc ($rc): $out"; fail=1; }
2169
+ echo "$out" | grep -qi "al día" && echo "OK empty_delta se anuncia como grafo al día" \
2170
+ || { echo "FAIL mensaje de empty_delta ($out)"; fail=1; }
2171
+ ops_graph_sync_ledger_read | python3 -c "
2172
+ import json,sys
2173
+ ps=json.load(sys.stdin)['proposals']
2174
+ print('SI' if all(p.get('status')!='PROPOSED' for p in ps) else 'NO')" | grep -q SI \
2175
+ && echo "OK empty_delta no deja una propuesta viva en el ledger" \
2176
+ || { echo "FAIL empty_delta dejó una propuesta viva"; fail=1; }
2177
+
2178
+ # 422: bundle inválido para el hub -> rechazo, con el motivo del hub a la vista.
2179
+ python3 - "$TMP/routes-gs.json" <<'PYX'
2180
+ import json,sys
2181
+ d=json.load(open(sys.argv[1]))
2182
+ d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":422,"body":{"detail":"la épica EP-002 depende de EP-999, que no existe"}}
2183
+ json.dump(d,open(sys.argv[1],"w"))
2184
+ PYX
2185
+ rm -f "$(ops_graph_sync_ledger_path)"
2186
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
2187
+ rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
2188
+ [ "$rc" = 6 ] && echo "OK graph-sync 422 -> rc 6" || { echo "FAIL 422 rc ($rc): $out"; fail=1; }
2189
+ echo "$out" | grep -q "EP-999" && echo "OK el 422 muestra el motivo del hub" \
2190
+ || { echo "FAIL motivo del 422 ($out)"; fail=1; }
2191
+
2192
+ # 409 graph_version_stale: se refresca el contexto y se reintenta UNA vez con el MISMO cid.
2193
+ python3 - "$TMP/routes-gs.json" <<'PYX'
2194
+ import json,sys
2195
+ d=json.load(open(sys.argv[1]))
2196
+ d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":409,"body":{"detail":{"error":"graph_version_stale","message":"el grafo avanzo","graph_version":9}}}
2197
+ d["GET /agent/context"]["body"]["context"]["graph_version"]=9
2198
+ json.dump(d,open(sys.argv[1],"w"))
2199
+ PYX
2200
+ rm -f "$(ops_graph_sync_ledger_path)"
2201
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
2202
+ runtime_graph_clear_stale
2203
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v1","version":4,"graph_version":4}}'
2204
+ : > "$TMP/calls-gs.jsonl"
2205
+ rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
2206
+ n="$(grep -c '"path": "/projects/p-1/agent/graph-sync-proposals"' "$TMP/calls-gs.jsonl" | tr -d ' ')"
2207
+ [ "$n" = 2 ] && echo "OK el 409 stale provoca UN reintento (2 envíos)" \
2208
+ || { echo "FAIL reintentos del 409 ($n): $out"; fail=1; }
2209
+ python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
2210
+ && echo "OK el reintento reusa el MISMO client_event_id y re-estampa la versión" \
2211
+ || { echo "FAIL identidad del reintento"; fail=1; }
2212
+ import json,sys
2213
+ envs=[json.loads(json.loads(l)["body"]) for l in open(sys.argv[1])
2214
+ if json.loads(l)["path"].endswith("/agent/graph-sync-proposals")]
2215
+ ok = (len(envs)==2 and envs[0]["client_event_id"]==envs[1]["client_event_id"]
2216
+ and envs[1]["graph_version"]==9)
2217
+ print("SI" if ok else "NO %s" % json.dumps(envs))
2218
+ PYX
2219
+ echo "$out" | grep -qi "rancio\|grafo" && echo "OK el 409 stale se explica al usuario" \
2220
+ || { echo "FAIL mensaje del 409 ($out)"; fail=1; }
2221
+
2222
+ # 404 anti-oráculo: el mensaje no puede afirmar cuál de las dos causas fue.
2223
+ python3 - "$TMP/routes-gs.json" <<'PYX'
2224
+ import json,sys
2225
+ d=json.load(open(sys.argv[1]))
2226
+ d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":404,"body":{"detail":"Proyecto no encontrado"}}
2227
+ json.dump(d,open(sys.argv[1],"w"))
2228
+ PYX
2229
+ rm -f "$(ops_graph_sync_ledger_path)"
2230
+ rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
2231
+ runtime_graph_clear_stale
2232
+ rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
2233
+ [ "$rc" = 6 ] && echo "OK graph-sync 404 -> rc 6" || { echo "FAIL 404 rc ($rc): $out"; fail=1; }
2234
+ echo "$out" | grep -qi "indistinguible\|no es el tuyo" \
2235
+ && echo "OK el 404 se explica como anti-oráculo" || { echo "FAIL mensaje del 404 ($out)"; fail=1; }
2236
+
2237
+ # --- graph-sync-status: el veredicto humano, legible por la terminal que propuso ---
2238
+ # El motivo del rechazo lo escribe el ADMIN precisamente para que lo lea quien propuso: sin
2239
+ # este subcomando el veredicto se queda en la consola y el agente vuelve a proponer lo mismo.
2240
+ rm -f "$(ops_graph_sync_ledger_path)"
2241
+ ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:1","status":"PROPOSED","proposal_id":"gs-7"}'
2242
+ ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:2","status":"PROPOSED","proposal_id":"gs-8"}'
2243
+ cat > "$TMP/routes-gs.json" <<'JSON'
2244
+ {"GET /projects/p-1/agent/graph-sync-proposals/gs-7": {"status": 200, "body": {"id": "gs-7", "status": "APPROVED", "graph_version": 4, "graph_version_applied": 5, "decided_by": "u-1", "reject_reason": null}},
2245
+ "GET /projects/p-1/agent/graph-sync-proposals/gs-8": {"status": 200, "body": {"id": "gs-8", "status": "REJECTED", "graph_version": 4, "reject_reason": "EP-014 no está en el PRD aprobado", "decided_by": "u-1"}}}
2246
+ JSON
2247
+ rc=0; out="$(bash "$SO" graph-sync-status 2>&1)" || rc=$?
2248
+ [ "$rc" = 0 ] && echo "OK graph-sync-status -> rc 0" || { echo "FAIL graph-sync-status rc ($rc): $out"; fail=1; }
2249
+ echo "$out" | grep -q "APPROVED" && echo "$out" | grep -q "v5" \
2250
+ && echo "OK graph-sync-status muestra APPROVED con la versión aplicada" \
2251
+ || { echo "FAIL render de APPROVED ($out)"; fail=1; }
2252
+ echo "$out" | grep -q "EP-014 no está en el PRD aprobado" \
2253
+ && echo "OK graph-sync-status muestra el motivo del rechazo que escribió el ADMIN" \
2254
+ || { echo "FAIL motivo del rechazo ($out)"; fail=1; }
2255
+ ops_graph_sync_ledger_read | python3 -c "
2256
+ import json,sys
2257
+ ps={p['proposal_id']:p for p in json.load(sys.stdin)['proposals']}
2258
+ print('SI' if ps['gs-7']['status']=='APPROVED' and ps['gs-8']['status']=='REJECTED' else 'NO')" \
2259
+ | grep -q SI && echo "OK el ledger se actualiza con el veredicto" \
2260
+ || { echo "FAIL ledger tras el veredicto"; fail=1; }
2261
+
2262
+ out="$(bash "$SO" graph-sync-status --id gs-7 2>&1)"
2263
+ echo "$out" | grep -q "gs-7" && ! echo "$out" | grep -q "gs-8" \
2264
+ && echo "OK --id filtra a una propuesta" || { echo "FAIL --id no filtra ($out)"; fail=1; }
2265
+
2266
+ rm -f "$(ops_graph_sync_ledger_path)"
2267
+ rc=0; out="$(bash "$SO" graph-sync-status 2>&1)" || rc=$?
2268
+ [ "$rc" = 0 ] && echo "$out" | grep -qi "ninguna\|no hay" \
2269
+ && echo "OK graph-sync-status sin propuestas -> rc 0 y lo dice" \
2270
+ || { echo "FAIL sin propuestas (rc $rc): $out"; fail=1; }
2271
+
2272
+ rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" graph-sync-status 2>&1)" || rc=$?
2273
+ [ "$rc" = 3 ] && echo "OK graph-sync-status en legacy -> rc 3" || { echo "FAIL status legacy rc ($rc)"; fail=1; }
2274
+
2275
+ # Un 404 al consultar es el MISMO anti-oráculo: propuesta ajena o inexistente, sin distinguir.
2276
+ ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:9","status":"PROPOSED","proposal_id":"gs-99"}'
2277
+ out="$(bash "$SO" graph-sync-status --id gs-99 2>&1)"
2278
+ echo "$out" | grep -qi "indistinguible\|no es de este proyecto\|desconocida" \
2279
+ && echo "OK una propuesta que el hub no reconoce se explica sin oráculo" \
2280
+ || { echo "FAIL 404 del status ($out)"; fail=1; }
2281
+ rm -f "$(ops_graph_sync_ledger_path)"
2282
+
2283
+ # ══════════════ espejo dual: el cuerpo que el hub acepta de verdad [EP-OR-17] ══════════════
2284
+ [ "$OPS_RC_NOT_MIRRORABLE" = 8 ] && echo "OK OPS_RC_NOT_MIRRORABLE=8" || { echo "FAIL rc del espejo"; fail=1; }
2285
+
2286
+ # Réplica local de TIPOS_DE_ESPEJO del hub (event_catalog.py: los schemas que heredan de
2287
+ # _TransicionDeEspejo). Test ANTI-DRIFT: si el hub amplía la enumeración, esta lista tiene que
2288
+ # ampliarse a la vez — dos listas del mismo hecho divergen, y la que calla es la que miente.
2289
+ esperados="checkpoint_recorded gate_verdict handoff_recorded phase_advanced phase_reverted progress_noted slice_opened wiring_checklist_seeded wiring_item_updated"
2290
+ got="$(echo $OPS_MIRROR_TYPES | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')"
2291
+ [ "$got" = "$esperados" ] && echo "OK OPS_MIRROR_TYPES replica la enumeración normativa del hub" \
2292
+ || { echo "FAIL OPS_MIRROR_TYPES ($got)"; fail=1; }
2293
+
2294
+ echo '{"gate":"security","status":"pass"}' > "$TMP/mir-payload.json"
2295
+ cat > "$TMP/routes-gs.json" <<'JSON'
2296
+ {"POST /projects/p-1/mirror/transitions": {"status": 200, "body": {"results": [{"client_event_id": "c1", "epic_code": "EP-003", "event_type": "gate_verdict", "status": "applied", "reason": null}], "applied": 1, "duplicates": 0, "rejected": 0}}}
2297
+ JSON
2298
+
2299
+ # Un tipo fuera de la enumeración NO gasta red: hoy viajaba y volvía rechazado por elemento, y
2300
+ # el aviso genérico mandaba al usuario a revisar una conexión que funcionaba.
2301
+ : > "$TMP/calls-gs.jsonl"
2302
+ rc=0; err="$(ops_mirror release_verdict_reported "$TMP/mir-payload.json" EP-003 2>&1)" || rc=$?
2303
+ [ "$rc" = 8 ] && echo "OK un tipo no espejable -> rc 8" || { echo "FAIL tipo no espejable rc ($rc)"; fail=1; }
2304
+ grep -q 'mirror/transitions' "$TMP/calls-gs.jsonl" && { echo "FAIL un tipo no espejable tocó la red"; fail=1; } \
2305
+ || echo "OK un tipo no espejable no abre socket"
2306
+ echo "$err" | grep -qi "catálogo\|enumeración" \
2307
+ && echo "OK el aviso dice POR QUÉ no se espeja" || { echo "FAIL aviso del tipo no espejable ($err)"; fail=1; }
2308
+
2309
+ # Sin epic_code no se envía: el hub lo exige y el lote sería un 422 garantizado.
2310
+ : > "$TMP/calls-gs.jsonl"
2311
+ rm -f "$PROJ/.claude/state/build-state.json"
2312
+ rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" "" >/dev/null 2>&1 || rc=$?
2313
+ [ "$rc" = 5 ] && echo "OK sin epic_code -> rc 5, sin red" || { echo "FAIL sin epic_code rc ($rc)"; fail=1; }
2314
+ grep -q 'mirror/transitions' "$TMP/calls-gs.jsonl" && { echo "FAIL sin epic_code tocó la red"; fail=1; } \
2315
+ || echo "OK sin epic_code no abre socket"
2316
+
2317
+ # El epic_code cae al estado local cuando no se pasa: en dual el fichero es primario.
2318
+ echo '{"active_slice":{"epica":"EP-077","phase":"green"}}' > "$PROJ/.claude/state/build-state.json"
2319
+ : > "$TMP/calls-gs.jsonl"
2320
+ ops_mirror gate_verdict "$TMP/mir-payload.json" >/dev/null 2>&1
2321
+ python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
2322
+ && echo "OK el epic_code cae a active_slice.epica del estado local" \
2323
+ || { echo "FAIL fallback del epic_code"; fail=1; }
2324
+ import json,sys
2325
+ env=None
2326
+ for line in open(sys.argv[1]):
2327
+ c=json.loads(line)
2328
+ if c["path"].endswith("/mirror/transitions"):
2329
+ env=json.loads(c["body"])
2330
+ print("SI" if env and env["transitions"][0]["epic_code"]=="EP-077" else "NO %s" % json.dumps(env))
2331
+ PYX
2332
+
2333
+ # El cuerpo es {"transitions":[{client_event_id, epic_code, event_type, payload}]} — y el
2334
+ # payload NO lleva claves de más (los schemas del catálogo son extra="forbid").
2335
+ : > "$TMP/calls-gs.jsonl"
2336
+ rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 >/dev/null 2>&1 || rc=$?
2337
+ [ "$rc" = 0 ] && echo "OK espejo aplicado -> rc 0" || { echo "FAIL espejo aplicado rc ($rc)"; fail=1; }
2338
+ python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
2339
+ && echo "OK el espejo manda el shape MirrorTransitionsIn" || { echo "FAIL shape del espejo"; fail=1; }
2340
+ import json,sys
2341
+ env=None
2342
+ for line in open(sys.argv[1]):
2343
+ c=json.loads(line)
2344
+ if c["path"].endswith("/mirror/transitions"):
2345
+ env=json.loads(c["body"])
2346
+ t=(env or {}).get("transitions") or [{}]
2347
+ ok = (list((env or {}).keys())==["transitions"] and len(t)==1
2348
+ and set(t[0].keys())=={"client_event_id","epic_code","event_type","payload"}
2349
+ and t[0]["epic_code"]=="EP-003" and t[0]["event_type"]=="gate_verdict"
2350
+ and 1 <= len(t[0]["client_event_id"]) <= 64
2351
+ and set(t[0]["payload"].keys())=={"gate","status"})
2352
+ print("SI" if ok else "NO %s" % json.dumps(env))
2353
+ PYX
2354
+
2355
+ # Un duplicado no es un fallo: el hub deduplica por client_event_id y el trabajo local siguió.
2356
+ python3 - "$TMP/routes-gs.json" <<'PYX'
2357
+ import json,sys
2358
+ d=json.load(open(sys.argv[1]))
2359
+ r=d["POST /projects/p-1/mirror/transitions"]["body"]
2360
+ r["results"][0]["status"]="duplicate"; r["applied"]=0; r["duplicates"]=1
2361
+ json.dump(d,open(sys.argv[1],"w"))
2362
+ PYX
2363
+ rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 >/dev/null 2>&1 || rc=$?
2364
+ [ "$rc" = 0 ] && echo "OK espejo duplicado -> rc 0" || { echo "FAIL espejo duplicado rc ($rc)"; fail=1; }
2365
+
2366
+ # Un rechazo POR ELEMENTO se lee y se muestra: es el motivo, no el cuerpo crudo.
2367
+ python3 - "$TMP/routes-gs.json" <<'PYX'
2368
+ import json,sys
2369
+ d=json.load(open(sys.argv[1]))
2370
+ r=d["POST /projects/p-1/mirror/transitions"]["body"]
2371
+ r["results"][0]["status"]="rejected"
2372
+ r["results"][0]["reason"]="la épica 'EP-003' no existe en el grafo del proyecto"
2373
+ r["applied"]=0; r["duplicates"]=0; r["rejected"]=1
2374
+ json.dump(d,open(sys.argv[1],"w"))
2375
+ PYX
2376
+ rc=0; err="$(ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 2>&1)" || rc=$?
2377
+ [ "$rc" = 6 ] && echo "OK espejo rechazado -> rc 6" || { echo "FAIL espejo rechazado rc ($rc)"; fail=1; }
2378
+ echo "$err" | grep -q "no existe en el grafo" \
2379
+ && echo "OK el rechazo del espejo muestra el motivo POR ELEMENTO" \
2380
+ || { echo "FAIL motivo del rechazo del espejo ($err)"; fail=1; }
2381
+ echo "$err" | grep -q "graph-sync" \
2382
+ && echo "OK el rechazo por épica ausente apunta a graph-sync" \
2383
+ || { echo "FAIL el rechazo no sugiere graph-sync ($err)"; fail=1; }
2384
+ rm -f "$PROJ/.claude/state/build-state.json"
2385
+
1936
2386
  # ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
1937
2387
  GB="$ROOT/scripts/lib/graph-bundle.py"
1938
2388
  cat > "$TMP/epics.json" <<'JSON'
@@ -2087,6 +2537,66 @@ grep -q "EP-020 tiene sección" "$TMP/gb10.err" && grep -q "EP-021 no aparece co
2087
2537
 
2088
2538
  # 4-ter) Las dos banderas juntas son un error explícito (ignorar una en silencio es el
2089
2539
  # patrón que causó #56).
2540
+ # ══════════ graph-bundle.py --for-sync [EP-OR-17] ══════════
2541
+ # La proyección de contrato del carril `graph-sync`. `GraphSyncBundle` es extra="forbid" y
2542
+ # admite SOLO `epics`: cualquier clave de preparación (bundle_version, kind, project_ref,
2543
+ # warnings…) es un 422. Y lo que en el bundle de preparación es AVISO aquí es RECHAZO: sin un
2544
+ # ADMIN revisando antes de subirlo, un aviso ignorado es el 422 del hub con pasos extra.
2545
+ cat > "$TMP/sync-in.json" <<'JSON'
2546
+ {"project_ref": "demo",
2547
+ "epics": [{"code": "EP-002", "title": "Cobros", "layer": "business",
2548
+ "files_scope": ["src/cobros/**"], "depends_on": ["EP-001"],
2549
+ "stories": [{"id": "HU-011", "title": "Registrar cobro"}]},
2550
+ {"code": "EP-001", "title": "Cimiento", "layer": "foundational",
2551
+ "stories": []}],
2552
+ "release_lines": [{"id": "R1", "epics": ["EP-001", "EP-002"]}]}
2553
+ JSON
2554
+ rc=0; out="$(python3 "$GB" --for-sync < "$TMP/sync-in.json" 2>"$TMP/sync-err.txt")" || rc=$?
2555
+ [ "$rc" = 0 ] && echo "OK --for-sync con bundle válido -> rc 0" \
2556
+ || { echo "FAIL --for-sync rc ($rc): $(cat "$TMP/sync-err.txt")"; fail=1; }
2557
+ printf '%s' "$out" | python3 -c "
2558
+ import json,sys
2559
+ d=json.load(sys.stdin)
2560
+ e=d['epics']
2561
+ ok = (list(d.keys())==['epics'] and len(e)==2 and e[0]['code']=='EP-001'
2562
+ and e[1]['layer']=='BUSINESS' and e[1]['stories'][0]['code']=='HU-011'
2563
+ and e[1]['release_line']=='R1'
2564
+ and not any(k in e[0] for k in ('warnings','bundle_version','kind','project_ref')))
2565
+ print('SI' if ok else 'NO')" | grep -q SI \
2566
+ && echo "OK --for-sync emite solo {epics:[…]} con el shape del contrato" \
2567
+ || { echo "FAIL shape de --for-sync ($out)"; fail=1; }
2568
+
2569
+ echo '{"epics":[{"code":"EP-009","title":"Sin capa"}]}' > "$TMP/sync-nolayer.json"
2570
+ rc=0; python3 "$GB" --for-sync < "$TMP/sync-nolayer.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
2571
+ [ "$rc" = 2 ] && echo "OK --for-sync sin layer -> rc 2" || { echo "FAIL layer ausente rc ($rc)"; fail=1; }
2572
+ grep -q "EP-009" "$TMP/sync-err.txt" && grep -qi "layer" "$TMP/sync-err.txt" \
2573
+ && echo "OK el rechazo nombra la épica y el campo" \
2574
+ || { echo "FAIL mensaje de layer ausente ($(cat "$TMP/sync-err.txt"))"; fail=1; }
2575
+
2576
+ python3 -c "
2577
+ import json
2578
+ hs=[{'id':'HU-%03d'%i,'title':'h%d'%i} for i in range(101)]
2579
+ print(json.dumps({'epics':[{'code':'EP-010','title':'Muchas','layer':'BUSINESS','stories':hs}]}))" > "$TMP/sync-techo.json"
2580
+ rc=0; python3 "$GB" --for-sync < "$TMP/sync-techo.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
2581
+ [ "$rc" = 2 ] && echo "OK --for-sync con 101 historias -> rc 2" || { echo "FAIL techo de historias rc ($rc)"; fail=1; }
2582
+ grep -q "100" "$TMP/sync-err.txt" && echo "OK el rechazo por techo dice cuál es el techo" \
2583
+ || { echo "FAIL mensaje de techo ($(cat "$TMP/sync-err.txt"))"; fail=1; }
2584
+
2585
+ echo '{"epics":[]}' > "$TMP/sync-vacio.json"
2586
+ rc=0; python3 "$GB" --for-sync < "$TMP/sync-vacio.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
2587
+ [ "$rc" = 2 ] && echo "OK --for-sync sin épicas -> rc 2" || { echo "FAIL bundle vacío rc ($rc)"; fail=1; }
2588
+
2589
+ # El modo de preparación NO cambia: sigue tolerando lo que el ADMIN va a revisar.
2590
+ rc=0; out="$(python3 "$GB" < "$TMP/sync-nolayer.json" 2>/dev/null)" || rc=$?
2591
+ [ "$rc" = 0 ] && printf '%s' "$out" | grep -q '"bundle_version"' \
2592
+ && echo "OK el modo de preparación sigue tolerando layer ausente (rc 0 + avisos)" \
2593
+ || { echo "FAIL regresión en el modo de preparación (rc $rc)"; fail=1; }
2594
+
2595
+ # --for-sync se combina con --from-docs: las dependencias en prosa también viajan al hub.
2596
+ rc=0; out="$(python3 "$GB" --for-sync --from-docs "$TMP/docs/epicas.md" < "$TMP/sync-in.json" 2>/dev/null)" || rc=$?
2597
+ [ "$rc" = 0 ] && echo "OK --for-sync se combina con --from-docs" \
2598
+ || { echo "FAIL --for-sync con --from-docs (rc $rc)"; fail=1; }
2599
+
2090
2600
  echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/epicas.md" --print-deps "$TMP/docs/epicas.md" > /dev/null 2> "$TMP/gb11.err"
2091
2601
  [ "$?" = 1 ] && grep -qi "excluyentes" "$TMP/gb11.err" \
2092
2602
  && echo "OK --from-docs y --print-deps juntas -> rc 1 explícito" || { echo "FAIL banderas excluyentes"; fail=1; }
@@ -2105,7 +2615,7 @@ PORT_GV="$(freeport)"
2105
2615
  cat > "$TMP/routes-gv.json" <<JSON
2106
2616
  {"GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": {}, "nudges": [],
2107
2617
  "context": {"manifest_hash": "sha256:v2", "version": 9, "graph_version": 45}}},
2108
- "POST /tasks/next": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 45}}}
2618
+ "POST /tasks/next": {"status": 409, "body": {"detail": {"error": "graph_version_stale", "message": "el grafo avanzo", "graph_version": 45}}}}
2109
2619
  JSON
2110
2620
  : > "$TMP/calls-gv.jsonl"
2111
2621
  python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GV" "$TMP/routes-gv.json" "$TMP/calls-gv.jsonl" &
@@ -2165,6 +2675,51 @@ PY
2165
2675
  [ "$has" = NO ] && [ $rc -eq 7 ] && echo "OK sin hub que versione, el claim no manda nada nuevo" \
2166
2676
  || { echo "FAIL compatibilidad hacia atras del claim (has=$has rc=$rc)"; fail=1; }
2167
2677
 
2678
+ # --- [EP-OR-17] Los OTROS motivos del 409, como los emite el hub de verdad ---
2679
+ # El `case` esperaba `reason: drift|contention|…` en la raíz y el hub anida todo bajo `detail`:
2680
+ # los cuatro casos caían en el `*)` y salían como rechazo duro. Un drift es REINTENTABLE tras
2681
+ # sincronizar el contexto, y reportarlo como rechazo dejaba al agente sin claim y sin salida.
2682
+ python3 - "$TMP/routes-gv.json" <<'PY'
2683
+ import json,sys
2684
+ p=sys.argv[1]; d=json.load(open(p))
2685
+ d["POST /tasks/next"]={"status":409,"body":{"detail":{"message":"tu contexto local no coincide","manifest_hash":"sha256:v9"}}}
2686
+ json.dump(d,open(p,"w"))
2687
+ PY
2688
+ : > "$TMP/calls-gv.jsonl"
2689
+ out="$(bash "$SO" claim 2>&1)"; rc=$?
2690
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
2691
+ [ "$n" = 2 ] && echo "OK un 409 de drift (detail.manifest_hash) se reintenta una vez" \
2692
+ || { echo "FAIL reintentos del 409 de drift ($n)"; fail=1; }
2693
+ [ $rc -eq 7 ] && echo "OK un drift persistente acaba en NO_WORK, no en rechazo duro" \
2694
+ || { echo "FAIL rc del 409 de drift ($rc): $out"; fail=1; }
2695
+
2696
+ python3 - "$TMP/routes-gv.json" <<'PY'
2697
+ import json,sys
2698
+ p=sys.argv[1]; d=json.load(open(p))
2699
+ d["POST /tasks/next"]={"status":409,"body":{"detail":"contención con otra escritura del contexto: reintenta el claim"}}
2700
+ json.dump(d,open(p,"w"))
2701
+ PY
2702
+ : > "$TMP/calls-gv.jsonl"
2703
+ out="$(bash "$SO" claim 2>&1)"; rc=$?
2704
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
2705
+ [ "$n" = 2 ] && [ $rc -eq 7 ] && echo "OK un 409 de contención se reintenta una vez" \
2706
+ || { echo "FAIL 409 de contención (n=$n rc=$rc): $out"; fail=1; }
2707
+
2708
+ python3 - "$TMP/routes-gv.json" <<'PY'
2709
+ import json,sys
2710
+ p=sys.argv[1]; d=json.load(open(p))
2711
+ d["POST /tasks/next"]={"status":409,"body":{"detail":"el contexto vigente del proyecto no resuelve (manifiesto ausente): no hay trabajo sin contexto declarado"}}
2712
+ json.dump(d,open(p,"w"))
2713
+ PY
2714
+ : > "$TMP/calls-gv.jsonl"
2715
+ out="$(bash "$SO" claim 2>&1)"; rc=$?
2716
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
2717
+ [ "$n" = 1 ] && [ $rc -eq 6 ] && echo "OK un contexto irresoluble NO se reintenta (rc 6)" \
2718
+ || { echo "FAIL 409 de contexto irresoluble (n=$n rc=$rc): $out"; fail=1; }
2719
+ echo "$out" | grep -q "no hay trabajo sin contexto declarado" \
2720
+ && echo "OK el 409 enseña el motivo del hub en prosa, no el JSON crudo" \
2721
+ || { echo "FAIL prosa del 409 ($out)"; fail=1; }
2722
+
2168
2723
  kill "$STUB_GV" 2>/dev/null; wait "$STUB_GV" 2>/dev/null
2169
2724
 
2170
2725
  exit $fail