@trycore/spec-build-harness 0.14.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "trycore-spec-build-harness",
4
4
  "displayName": "Trycore — Spec & Build Harness",
5
- "version": "0.14.1",
5
+ "version": "0.14.2",
6
6
  "description": "Arnés de construcción de dos loops (slice por épica + release gate) para Claude Code, con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
7
7
  "author": {
8
8
  "name": "Trycore",
package/README.md CHANGED
@@ -198,8 +198,10 @@ El core no menciona ningún dominio de cliente. Toda parametrización entra por
198
198
  día—. **(3)** `claim` y la propuesta mandan la **versión de grafo** conocida, estampada *al
199
199
  despachar* y no al encolar; un `409` por grafo rancio provoca refresco y un reintento en vez de
200
200
  un fallo. `legacy` sigue siendo el default y no cambia. Las mitades de servidor de (2) y (3)
201
- (`trycore-ia-hub#113`/`#114`/`#115`) están pendientes: contra un hub sin ellas el cliente degrada
202
- a «instancia sin soporte». Total: **14 agentes**, **19 hooks**, **13 comandos `/build:*`**,
201
+ ya existen en el hub, en el **carril del agente** (`/projects/{id}/agent/epic-proposals`), y la
202
+ **v0.14.2** alineó al cliente con ese contrato: hasta entonces pegaba contra la ruta de consola
203
+ y la propuesta moría con `405`/`422` (#70). Contra un hub que no lo exponga, el cliente degrada a
204
+ «instancia sin soporte». Total: **14 agentes**, **19 hooks**, **13 comandos `/build:*`**,
203
205
  **16 skills**.
204
206
  - ✅ **v0.13.0 — el grafo llega al hub con aristas** — las dependencias entre épicas viven como
205
207
  prosa en `epicas.md` (`**Depende de**: EP-001`) y su lectura estaba delegada al modelo, con un
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.14.1
1
+ 0.14.2
@@ -49,6 +49,9 @@ Reúne del repo y de `docs/` lo necesario y **propón** al usuario, vía **AskUs
49
49
  título, objetivo, capa (`foundational` | `business` | `technical`), alcance de archivos y
50
50
  dependencias con épicas existentes. Las historias van con AC en Given/When/Then.
51
51
 
52
+ El grafo del hub solo distingue **dos** capas (`FOUNDATIONAL` y `BUSINESS`): una épica
53
+ `technical` viaja como `business` y eso es correcto, no un error que debas corregir.
54
+
52
55
  No inventes la clasificación de capa ni las dependencias: derívalas del PRD / Story Map y
53
56
  confírmalas. Sin aprobación explícita del usuario, **no propongas nada**.
54
57
 
@@ -61,10 +64,21 @@ Escribe el borrador en un fichero temporal (nunca por argv):
61
64
  "layer": "business",
62
65
  "files_scope": ["src/…/**"],
63
66
  "depends_on": ["EP-012"],
64
- "stories": [{"title": "…", "acceptance_criteria": "Dado … Cuando … Entonces …"}]
67
+ "stories": [
68
+ {
69
+ "title": "…",
70
+ "description": "Como … quiero … para …",
71
+ "acceptance_criteria": "Dado … Cuando … Entonces …. Dado … Cuando … Entonces …"
72
+ }
73
+ ]
65
74
  }
66
75
  ```
67
76
 
77
+ Escribe los criterios **completos** en `acceptance_criteria`: `slice-ops.sh` los parte en un
78
+ escenario por entrada y los manda como `acceptance[]`, que es lo que la consola del hub enseña
79
+ en el detalle de la historia (issue #70). Varios escenarios en un mismo campo se separan con
80
+ `. Dado …`; si prefieres darlos ya partidos, usa `"acceptance": ["Dado …", "Dado …"]` y manda.
81
+
68
82
  ## 2. Proponer
69
83
 
70
84
  ```bash
@@ -228,8 +228,8 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
228
228
  | `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
229
229
  | `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales; reporta además `outbox/rejected/` (conteo + tipo y razón del rechazo más reciente, issue #52) |
230
230
  | `slice-ops.sh escalate` | evento `slice_escalated {cause}` (gate y fase dentro del texto de la causa; exige slice activo en runtime) |
231
- | `slice-ops.sh propose-epic --file B` | `POST /projects/{id}/epic-proposals` (carril directo). Propone una épica **sin `EP-XXX`**: la identidad la asigna el hub al aprobar (hub#113). `0` enviada · `5` encolada · `6` rechazada · `3` legacy/dual. Un `epic_code` en el borrador se **ignora**, no se rechaza |
232
- | `slice-ops.sh epic-status [--id P]` | `GET /projects/{id}/epic-proposals/{P}`. Resuelve el ciclo: `QUEUED` → `PROPOSED` → `APPROVED` (con `epic_code`) / `REJECTED`. Informativo: `rc 0` siempre en runtime |
231
+ | `slice-ops.sh propose-epic --file B` | `POST /projects/{id}/agent/epic-proposals` (carril directo). Propone una épica **sin `EP-XXX`**: la identidad la asigna el hub al aprobar. `0` enviada · `5` encolada · `6` rechazada · `3` legacy/dual. Un `epic_code` en el borrador se **ignora**, no se rechaza. El identificador llega en `id` de `EpicProposalOut` |
232
+ | `slice-ops.sh epic-status [--id P]` | `GET /projects/{id}/agent/epic-proposals/{P}`. Resuelve el ciclo: `QUEUED` → `PROPOSED` → `APPROVED` (código en `assigned_code`) / `REJECTED` (motivo en `reject_reason`). Informativo: `rc 0` siempre en runtime |
233
233
  | `slice-ops.sh epic-writeback [--id P] [--file F]` | — (local). Escribe en `docs/03-backlog/epicas.md` las épicas ya `APPROVED`, con el código del hub. Mecánico e idempotente: el fichero es proyección del grafo. `--file F` permite otro fichero del backlog (partido en varios), pero **F queda acotado al subárbol `docs/03-backlog/` del proyecto** —`realpath` sobre ambos lados, así que ni symlinks ni `..` escapan— porque los contactos de escritura del arnés en `docs/` son cuatro y acotados (METODOLOGIA §9.2) y este comando promete tocar solo el backlog. Fuera de ahí, `2` con la ruta permitida en el mensaje y **cero escritura**, exista el fichero o no. `0` escribió · `7` nada pendiente · `4` sin fichero · `2` `--file` fuera del carve-out |
234
234
  | `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts`; **sin fallback offline** (rc 5 y reintento al reconectar: el agregado `release` no entra por `POST /events`, issue #44) |
235
235
  | `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
@@ -244,14 +244,27 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
244
244
  "layer": "foundational|business|technical",
245
245
  "files_scope": ["…"],
246
246
  "depends_on": ["EP-012"],
247
- "stories": [{"title": "…", "acceptance_criteria": "…"}]
247
+ "stories": [{"title": "…", "description": "…", "acceptance_criteria": "Dado Cuando … Entonces …"}]
248
248
  }
249
249
  ```
250
250
 
251
251
  `title` y `objective` son obligatorios; `layer` por defecto `business` (rc 2 si no es una de las
252
252
  tres). Un `epic_code`/`code`/`id` en el borrador **se ignora con aviso**, no se rechaza la
253
- propuesta — la identidad la asigna el hub al aprobar (hub#113). El normalizador estampa
254
- `origin: "harness-draft"` antes de encolar.
253
+ propuesta — la identidad la asigna el hub al aprobar.
254
+
255
+ **El borrador NO es el payload** (issue #70). El normalizador traduce al contrato del agente
256
+ (`EpicProposalIn`) antes de encolar, y esa traducción es la única superficie que conoce los dos
257
+ vocabularios:
258
+
259
+ | Borrador (lo que escribe el agente) | Contrato del hub |
260
+ |---|---|
261
+ | `layer: "foundational"` | `layer: "FOUNDATIONAL"` |
262
+ | `layer: "business"` \| `"technical"` | `layer: "BUSINESS"` — el hub no tiene capa técnica; `technical` se **mapea**, no se rechaza |
263
+ | `stories[].acceptance_criteria` (prosa) | `stories[].acceptance[]` — un escenario Dado/Cuando/Entonces por entrada (se parte por líneas y por el `. Dado ` que une dos escenarios). Una `acceptance[]` ya explícita en el borrador manda |
264
+ | `stories[].description` | `stories[].description` (tal cual) |
265
+
266
+ El hub descarta los campos que no reconoce (`extra="ignore"`): mandar `acceptance_criteria`
267
+ **no da error**, simplemente llega una historia sin un solo criterio a la consola.
255
268
 
256
269
  **Códigos de salida** (contrato con la prosa): `0` ok · `2` uso · `3` modo legacy (o claim en dual)
257
270
  · `4` sin slice activo · `5` offline (encolado si el tipo tiene camino por la cola; si no, reintento manual al reconectar) · `6` rechazado por el servidor (no reintentar) ·
@@ -112,8 +112,11 @@ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
112
112
  # (`EP-XXX`) al aprobar y un humano aprueba: por eso hay una ruta de creación y otra de
113
113
  # consulta, y ninguna de publicación. Mismo patrón que las propuestas de asset
114
114
  # (`ops_path_proposals`), que es su precedente en este cliente.
115
- ops_path_epic_proposals() { echo "/projects/$1/epic-proposals"; }
116
- ops_path_epic_proposal() { echo "/projects/$1/epic-proposals/$2"; }
115
+ # [#70] El carril del AGENTE lleva `/agent/`: `/projects/{id}/epic-proposals` (sin él) es la
116
+ # vista de CONSOLA — GET con JWT de usuario — y responde `405 Allow: GET` a un POST con token
117
+ # de proyecto. Las dos superficies no se comparten por diseño (hub CON-1).
118
+ ops_path_epic_proposals() { echo "/projects/$1/agent/epic-proposals"; }
119
+ ops_path_epic_proposal() { echo "/projects/$1/agent/epic-proposals/$2"; }
117
120
 
118
121
  ops_mode() { runtime_mode; }
119
122
 
@@ -485,8 +485,29 @@ _epic_draft_normalize() {
485
485
  # el motivo del rc 2 (validación) o el aviso de `epic_code` ignorado; silenciarlo aquí
486
486
  # los perdería a los dos.
487
487
  python3 - "$f" <<'PY'
488
- import json,sys
489
- CAPAS={"foundational","business","technical"}
488
+ import json,re,sys
489
+ # [#70] El vocabulario del BORRADOR (lo que escribe el agente) y el del CONTRATO del hub
490
+ # (`EpicProposalIn`) no son el mismo: el borrador habla en minúsculas y conoce `technical`;
491
+ # el hub solo acepta el literal FOUNDATIONAL|BUSINESS. La traducción vive aquí, en el borde,
492
+ # porque rechazar `technical` sería castigar al agente por una distinción que el grafo no
493
+ # hace: para el hub una épica técnica es de negocio.
494
+ CAPAS={"foundational":"FOUNDATIONAL","business":"BUSINESS","technical":"BUSINESS"}
495
+
496
+ def escenarios(txt):
497
+ """Criterios en prosa -> `acceptance[]` del contrato: un escenario Dado/Cuando/Entonces
498
+ por entrada. Se parte por líneas y, dentro de cada una, por el `. Dado ` que separa dos
499
+ escenarios seguidos en un mismo párrafo."""
500
+ out=[]
501
+ for linea in str(txt).splitlines():
502
+ linea=linea.strip().lstrip("-*•").strip()
503
+ if not linea:
504
+ continue
505
+ for trozo in re.split(r"\.\s+(?=Dado\s)", linea):
506
+ trozo=trozo.strip()
507
+ if trozo:
508
+ out.append(trozo[:2000])
509
+ return out[:20]
510
+
490
511
  try:
491
512
  d=json.load(open(sys.argv[1]))
492
513
  except Exception:
@@ -499,29 +520,77 @@ if not title:
499
520
  print("⛔ el borrador no trae `title` (título de la épica)", file=sys.stderr); raise SystemExit(2)
500
521
  if not objective:
501
522
  print("⛔ el borrador no trae `objective` (objetivo de la épica)", file=sys.stderr); raise SystemExit(2)
502
- layer=d.get("layer") or "business"
523
+ layer=str(d.get("layer") or "business").strip().lower()
503
524
  if layer not in CAPAS:
504
525
  print("⛔ `layer` debe ser uno de: %s" % ", ".join(sorted(CAPAS)), file=sys.stderr); raise SystemExit(2)
505
526
  ignorados=[k for k in ("epic_code","code","id") if d.get(k)]
506
527
  stories=[]
507
528
  for s in d.get("stories") or []:
508
529
  if isinstance(s,str):
509
- stories.append({"title":s})
530
+ stories.append({"title":s[:300]})
510
531
  elif isinstance(s,dict) and (s.get("title") or "").strip():
511
- st={"title":s["title"].strip()}
512
- if s.get("acceptance_criteria"):
513
- st["acceptance_criteria"]=str(s["acceptance_criteria"])[:2000]
532
+ st={"title":s["title"].strip()[:300]}
533
+ if s.get("description"):
534
+ st["description"]=str(s["description"])[:2000]
535
+ # La `acceptance` explícita del borrador manda; si no, se derivan de los criterios en
536
+ # prosa. `acceptance_criteria` NO viaja: el hub descarta el campo en el borde
537
+ # (`extra="ignore"`) y la consola enseñaba las historias sin un solo criterio.
538
+ acc=s.get("acceptance")
539
+ if isinstance(acc,list):
540
+ acc=[str(x).strip()[:2000] for x in acc if str(x).strip()][:20]
541
+ elif s.get("acceptance_criteria"):
542
+ acc=escenarios(s["acceptance_criteria"])
543
+ else:
544
+ acc=[]
545
+ if acc:
546
+ st["acceptance"]=acc
514
547
  stories.append(st)
515
- out={"title":title[:300],"objective":objective[:2000],"layer":layer,
548
+ out={"title":title[:300],"objective":objective[:2000],"layer":CAPAS[layer],
516
549
  "files_scope":[str(x) for x in (d.get("files_scope") or []) if isinstance(x,(str,int))],
517
550
  "depends_on":[str(x) for x in (d.get("depends_on") or []) if isinstance(x,(str,int))],
518
- "stories":stories,"origin":"harness-draft"}
551
+ "stories":stories}
519
552
  if ignorados:
520
553
  print("IGNORADOS %s" % ",".join(ignorados), file=sys.stderr)
521
554
  print(json.dumps(out,ensure_ascii=False))
522
555
  PY
523
556
  }
524
557
 
558
+ # [#70] `EpicProposalOut` del contrato nombra `id` al identificador de la propuesta (el
559
+ # borrador de hub#113 lo llamaba `proposal_id`). Sin esto la terminal decía «sin identificador
560
+ # en la respuesta» y el ledger guardaba "" — `epic-status` se quedaba sin a quién preguntar.
561
+ # Se acepta el nombre viejo como respaldo: un hub anterior al contrato sigue funcionando.
562
+ _epic_proposal_id() {
563
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
564
+ printf '%s' "$1" | python3 -c '
565
+ import json,sys
566
+ try:
567
+ d=json.load(sys.stdin)
568
+ r=(d.get("response") if isinstance(d.get("response"),dict) else d) or {}
569
+ print(r.get("id") or r.get("proposal_id") or "")
570
+ except Exception:
571
+ print("")
572
+ ' 2>/dev/null
573
+ }
574
+
575
+ # _epic_from_response <respuesta> — la épica aprobada como objeto para el ledger. El contrato
576
+ # la devuelve en la RAÍZ de `EpicProposalOut`; el borrador viejo la anidaba en `epic`.
577
+ _epic_from_response() {
578
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
579
+ printf '%s' "$1" | python3 -c '
580
+ import json,sys
581
+ try:
582
+ d=json.load(sys.stdin)
583
+ except Exception:
584
+ print("{}"); raise SystemExit(0)
585
+ if not isinstance(d,dict):
586
+ print("{}"); raise SystemExit(0)
587
+ e=d.get("epic")
588
+ if not isinstance(e,dict):
589
+ e={k:d[k] for k in ("title","objective","layer","stories","files_scope","depends_on") if k in d}
590
+ print(json.dumps(e,ensure_ascii=False))
591
+ ' 2>/dev/null || echo "{}"
592
+ }
593
+
525
594
  cmd_propose_epic() {
526
595
  local file="" mode pid norm errf rc cid pfile ack proposal_id reason
527
596
  while [ $# -gt 0 ]; do
@@ -590,7 +659,7 @@ cmd_propose_epic() {
590
659
  runtime_dispatch_outbox >/dev/null 2>&1
591
660
  ack="$(ops_epic_ack_read "$cid")"
592
661
  if [ -n "$ack" ]; then
593
- proposal_id="$(printf '%s' "$ack" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or {}).get("proposal_id") or "")' 2>/dev/null)"
662
+ proposal_id="$(_epic_proposal_id "$ack")"
594
663
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$proposal_id")}"
595
664
  echo "propuesta enviada: ${proposal_id:-sin identificador en la respuesta}"
596
665
  echo "Queda en PROPOSED: NO es reclamable hasta que un humano la apruebe en la consola del hub."
@@ -662,7 +731,7 @@ cmd_epic_status() {
662
731
  if [ "$st" = "QUEUED" ] && [ -n "$cid" ]; then
663
732
  ack="$(ops_epic_ack_read "$cid")"
664
733
  if [ -n "$ack" ]; then
665
- pidv="$(printf '%s' "$ack" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or {}).get("proposal_id") or "")' 2>/dev/null)"
734
+ pidv="$(_epic_proposal_id "$ack")"
666
735
  st=PROPOSED
667
736
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$pidv")}"
668
737
  else
@@ -686,13 +755,18 @@ cmd_epic_status() {
686
755
  case "$st" in
687
756
  APPROVED)
688
757
  local code epic
689
- code="$(_claim_field "$resp" epic_code)"
690
- epic="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin).get("epic") or {},ensure_ascii=False))' 2>/dev/null)"
758
+ # [#70] El código lo asigna el hub en `assigned_code` (EP-OR-15); `epic_code` era
759
+ # el nombre del borrador y se conserva como respaldo.
760
+ code="$(_claim_field "$resp" assigned_code)"
761
+ [ -n "$code" ] || code="$(_claim_field "$resp" epic_code)"
762
+ epic="$(_epic_from_response "$resp")"
691
763
  [ -n "$epic" ] || epic="{}"
692
764
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"APPROVED\",\"epic_code\":$(ops_json_str "$code"),\"epic\":$epic}"
693
765
  echo "$pidv APPROVED $code — escríbela con \`slice-ops.sh epic-writeback\`" ;;
694
766
  REJECTED)
695
- local why; why="$(_claim_field "$resp" reason)"
767
+ # [#70] El motivo del rechazo humano viaja en `reject_reason` (EP-OR-15).
768
+ local why; why="$(_claim_field "$resp" reject_reason)"
769
+ [ -n "$why" ] || why="$(_claim_field "$resp" reason)"
696
770
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"REJECTED\",\"reason\":$(ops_json_str "$why")}"
697
771
  echo "$pidv REJECTED ${why:-sin motivo registrado}" ;;
698
772
  *)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycore/spec-build-harness",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,9 +31,11 @@ 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
 
39
41
  # [#62] Ledger: upsert por client_event_id, sin duplicar, conservando lo no mencionado.
@@ -1618,9 +1620,13 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
1618
1620
  # ══════════════ slice-ops.sh: propose-epic / epic-status / epic-writeback [#62] ══════════════
1619
1621
  # Stub PROPIO (puerto, routes-ep.json y calls-ep.jsonl): routes-cl.json/STUB_CL ya no existen
1620
1622
  # en este punto del fichero y mutarlos rompería aserciones aguas arriba.
1623
+ # [#70] Las rutas y los cuerpos de este bloque son los del CONTRATO DEL AGENTE del hub
1624
+ # (`contrato-agente-openapi.json`, EP-OR-14/EP-OR-15), no los del borrador de hub#113:
1625
+ # carril `/agent/…`, `layer` en MAYÚSCULAS (FOUNDATIONAL|BUSINESS), historias con
1626
+ # `description`+`acceptance[]`, y respuesta `{id, status, assigned_code, reject_reason}`.
1621
1627
  PORT_EP="$(freeport)"
1622
1628
  cat > "$TMP/routes-ep.json" <<JSON
1623
- {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-77", "status": "PROPOSED"}}}
1629
+ {"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
1630
  JSON
1625
1631
  : > "$TMP/calls-ep.jsonl"
1626
1632
  python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_EP" "$TMP/routes-ep.json" "$TMP/calls-ep.jsonl" &
@@ -1634,7 +1640,8 @@ cat > "$TMP/draft.json" <<'JSON'
1634
1640
  {"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1635
1641
  "layer":"business","files_scope":["src/notifications/**"],"depends_on":["EP-012"],
1636
1642
  "epic_code":"EP-999",
1637
- "stories":[{"title":"ver el aviso","acceptance_criteria":"Dado Cuando Entonces "}]}
1643
+ "stories":[{"title":"ver el aviso","description":"Como usuario quiero ver el aviso sin recargar",
1644
+ "acceptance_criteria":"Dado un aviso nuevo Cuando llega Entonces se ve. Dado un aviso leído Cuando entro Entonces no se repite"}]}
1638
1645
  JSON
1639
1646
 
1640
1647
  # legacy y dual: rc 3 sin efectos (ni cola, ni red).
@@ -1659,22 +1666,59 @@ out="$(bash "$SO" propose-epic --file "$TMP/draft.json" 2>&1)"; rc=$?
1659
1666
  && echo "OK propose-epic envia y devuelve el identificador" \
1660
1667
  || { echo "FAIL propose-epic online (rc $rc): $out"; fail=1; }
1661
1668
 
1662
- # El EP-XXX del borrador se IGNORA (la identidad la asigna el hub, hub#113) — ignorar, no rechazar.
1669
+ # [#70] El cuerpo enviado valida contra `EpicProposalIn` del contrato del agente: `layer` en
1670
+ # MAYÚSCULAS, historias con `description`+`acceptance[]` y NUNCA `acceptance_criteria` (el
1671
+ # hub lo descarta en el borde con extra="ignore": la consola mostraba las HU sin criterios).
1663
1672
  body="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
1664
1673
  import json,sys
1665
1674
  for l in open(sys.argv[1]):
1666
1675
  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")
1676
+ if d["path"]=="/projects/p-1/agent/epic-proposals":
1677
+ b=json.loads(d["body"]); errores=[]
1678
+ if b.get("layer")!="BUSINESS": errores.append("layer=%r" % b.get("layer"))
1679
+ if "epic_code" in b: errores.append("viajo el epic_code del borrador")
1680
+ if not b.get("title","").startswith("Notificaciones"): errores.append("title=%r" % b.get("title"))
1681
+ s=(b.get("stories") or [{}])[0]
1682
+ if "acceptance_criteria" in s: errores.append("la historia lleva acceptance_criteria")
1683
+ if set(s)-{"title","description","acceptance","code","acceptance_ref"}:
1684
+ errores.append("campos fuera del contrato: %s" % sorted(set(s)-{"title","description","acceptance","code","acceptance_ref"}))
1685
+ if s.get("description")!="Como usuario quiero ver el aviso sin recargar":
1686
+ errores.append("description=%r" % s.get("description"))
1687
+ acc=s.get("acceptance")
1688
+ if not isinstance(acc,list) or len(acc)!=2: errores.append("acceptance=%r" % acc)
1689
+ elif not acc[0].startswith("Dado un aviso nuevo") or not acc[1].startswith("Dado un aviso leído"):
1690
+ errores.append("escenarios mal partidos: %r" % acc)
1691
+ print("SI" if not errores else "NO: "+" | ".join(errores))
1670
1692
  break
1671
1693
  PY
1672
1694
  )"
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; }
1695
+ [ "$body" = SI ] && echo "OK el cuerpo de propose-epic valida contra el contrato del agente" \
1696
+ || { echo "FAIL cuerpo de propose-epic ($body)"; fail=1; }
1675
1697
  echo "$out" | grep -qi 'ignorado' && echo "OK propose-epic avisa de que ignoro el codigo" \
1676
1698
  || { echo "FAIL sin aviso del codigo ignorado"; fail=1; }
1677
1699
 
1700
+ # [#70] `technical` no existe en el enum del hub (FOUNDATIONAL|BUSINESS): el 422 que devolvía
1701
+ # se evita mapeándolo a BUSINESS en el borde, no rechazando el borrador del agente.
1702
+ cat > "$TMP/draft-tech.json" <<'JSON'
1703
+ {"title":"Observabilidad","objective":"ver qué pasa en produccion","layer":"technical",
1704
+ "stories":[{"title":"ver las trazas"}]}
1705
+ JSON
1706
+ : > "$TMP/calls-ep.jsonl"
1707
+ bash "$SO" propose-epic --file "$TMP/draft-tech.json" >/dev/null 2>&1
1708
+ tech="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
1709
+ import json,sys
1710
+ for l in open(sys.argv[1]):
1711
+ d=json.loads(l)
1712
+ if d["path"]=="/projects/p-1/agent/epic-proposals":
1713
+ b=json.loads(d["body"])
1714
+ s=(b.get("stories") or [{}])[0]
1715
+ print("SI" if b.get("layer")=="BUSINESS" and "acceptance" not in s else "NO: layer=%r story=%r" % (b.get("layer"), s))
1716
+ break
1717
+ PY
1718
+ )"
1719
+ [ "$tech" = SI ] && echo "OK layer technical viaja como BUSINESS y una historia sin AC no inventa acceptance" \
1720
+ || { echo "FAIL mapeo de layer technical ($tech)"; fail=1; }
1721
+
1678
1722
  # El ledger recuerda la propuesta con su borrador.
1679
1723
  ops_epic_ledger_read | python3 -c "
1680
1724
  import json,sys
@@ -1710,14 +1754,18 @@ out="$(bash "$SO" propose-epic --file "$TMP/malo.json" 2>&1)"
1710
1754
  || { echo "FAIL validacion local del borrador: $out"; fail=1; }
1711
1755
 
1712
1756
  # --- [#62] epic-status ---
1757
+ # [#70] La respuesta es `EpicProposalOut` del contrato: el código aprobado viaja en
1758
+ # `assigned_code` y la épica en la RAÍZ (no anidada en `epic`).
1713
1759
  python3 - "$TMP/routes-ep.json" <<'PY'
1714
1760
  import json,sys
1715
1761
  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"}]}}}
1762
+ d["GET /projects/p-1/agent/epic-proposals/prop-77"]={"status":200,"body":{
1763
+ "id":"prop-77","project_id":"p-1","status":"APPROVED","assigned_code":"EP-045",
1764
+ "title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1765
+ "layer":"BUSINESS","depends_on":["EP-012"],"files_scope":["src/notifications/**"],
1766
+ "stories":[{"code":"HU-310","title":"ver el aviso","description":"Como usuario…",
1767
+ "acceptance":["Dado un aviso nuevo Cuando llega Entonces se ve"],"acceptance_ref":None},],
1768
+ "reject_reason":None}}
1721
1769
  json.dump(d,open(p,"w"))
1722
1770
  PY
1723
1771
  TRYCORE_RUNTIME_MODE=legacy bash "$SO" epic-status >/dev/null 2>&1
@@ -1733,20 +1781,38 @@ out="$(bash "$SO" epic-status 2>&1)"; rc=$?
1733
1781
  || { echo "FAIL epic-status (rc $rc): $out"; fail=1; }
1734
1782
  ops_epic_ledger_read | python3 -c "
1735
1783
  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')" \
1784
+ ps=json.load(sys.stdin)['proposals']
1785
+ p=[q for q in ps if q.get('proposal_id')=='prop-77'][0]
1786
+ 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
1787
  | grep -q SI && echo "OK epic-status guarda el codigo y el contenido aprobado" \
1739
1788
  || { echo "FAIL el ledger no recogio la aprobacion"; fail=1; }
1740
1789
 
1790
+ # [#70] El rechazo humano viaja en `reject_reason` (EP-OR-15): sin leerlo, la terminal decía
1791
+ # "sin motivo registrado" y el usuario no sabía qué corregir.
1792
+ python3 - "$TMP/routes-ep.json" <<'PY'
1793
+ import json,sys
1794
+ p=sys.argv[1]; d=json.load(open(p))
1795
+ d["GET /projects/p-1/agent/epic-proposals/prop-88"]={"status":200,"body":{
1796
+ "id":"prop-88","project_id":"p-1","status":"REJECTED","assigned_code":None,
1797
+ "title":"Otra","objective":"otra cosa","layer":"BUSINESS","depends_on":[],"files_scope":[],
1798
+ "stories":[],"reject_reason":"se solapa con EP-012"}}
1799
+ json.dump(d,open(p,"w"))
1800
+ PY
1801
+ ops_epic_ledger_upsert '{"client_event_id":"cid-r","proposal_id":"prop-88","status":"PROPOSED"}'
1802
+ out="$(bash "$SO" epic-status --id prop-88 2>&1)"; rc=$?
1803
+ [ $rc -eq 0 ] && echo "$out" | grep -q 'se solapa con EP-012' \
1804
+ && echo "OK epic-status muestra el motivo del rechazo humano" \
1805
+ || { echo "FAIL motivo del rechazo (rc $rc): $out"; fail=1; }
1806
+
1741
1807
  # Un hub sin soporte (404) se dice con esas palabras y NO rompe nada.
1742
1808
  python3 - "$TMP/routes-ep.json" <<'PY'
1743
1809
  import json,sys
1744
1810
  p=sys.argv[1]; d=json.load(open(p))
1745
- d.pop("GET /projects/p-1/epic-proposals/prop-77",None)
1811
+ d.pop("GET /projects/p-1/agent/epic-proposals/prop-77",None)
1746
1812
  json.dump(d,open(p,"w"))
1747
1813
  PY
1748
1814
  ops_epic_ledger_upsert '{"client_event_id":"cid-x","proposal_id":"prop-77","status":"PROPOSED"}'
1749
- out="$(bash "$SO" epic-status 2>&1)"; rc=$?
1815
+ out="$(bash "$SO" epic-status --id prop-77 2>&1)"; rc=$?
1750
1816
  [ $rc -eq 0 ] && echo "$out" | grep -qi 'sin soporte' \
1751
1817
  && echo "OK epic-status nombra la falta de soporte del hub" \
1752
1818
  || { echo "FAIL epic-status con 404 (rc $rc): $out"; fail=1; }