@trycore/spec-build-harness 0.14.2 → 0.16.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.
@@ -38,6 +38,13 @@ source "$ROOT/hooks/build/lib/runtime-ops.sh" 2>/dev/null || { echo "FAIL no exi
38
38
  [ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/agent/epic-proposals/prop-7" ] \
39
39
  && echo "OK ops_path_epic_proposal" || { echo "FAIL ops_path_epic_proposal"; fail=1; }
40
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
+
41
48
  # [#62] Ledger: upsert por client_event_id, sin duplicar, conservando lo no mencionado.
42
49
  rm -f "$(ops_epic_ledger_path)"
43
50
  ops_epic_ledger_upsert '{"client_event_id":"cid-1","status":"QUEUED","draft":{"title":"Notificaciones"}}'
@@ -144,7 +151,7 @@ fi
144
151
  rm -f "$PROJ/.claude/state/outbox/"*.json
145
152
 
146
153
  # --- ops_mirror: sólo tiene sentido con project_id; nunca lanza sin él ---
147
- ops_mirror gate_reported "$TMP/payload.json"; rc=$?
154
+ ops_mirror gate_verdict "$TMP/payload.json" EP-003; rc=$?
148
155
  [ "$rc" -ne 0 ] && echo "OK ops_mirror sin credenciales -> rc != 0" || { echo "FAIL ops_mirror sin credenciales"; fail=1; }
149
156
 
150
157
  # ══════════════ slice-ops.sh: mode / claim / next-step [Task 2] ══════════════
@@ -183,6 +190,56 @@ STUBS="$STUBS $!"
183
190
  sleep 0.3
184
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\"}"
185
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
+
186
243
  # (#39) `claim --epic` NO existe en el protocolo del hub: `TasksNextIn` no declara
187
244
  # epic_code (pydantic lo ignoraba y la cola repartía OTRA épica con claim "exitoso").
188
245
  # Debe fallar EXPLÍCITO antes de tocar la red.
@@ -927,16 +984,18 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
927
984
  [ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase en dual -> rc 0 sin encolar" || { echo "FAIL phase dual (rc=$rc n=$n)"; fail=1; }
928
985
  [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
929
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".
930
989
  tos=[]
931
990
  for line in open(sys.argv[1]):
932
991
  c=json.loads(line)
933
992
  if c["path"]=="/projects/p-1/mirror/transitions":
934
- b=json.loads(c["body"])
935
- if b.get("kind")=="phase_advanced":
936
- 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"]))
937
996
  print("SI" if tos==[("green","EP-009")] else "NO:"+repr(tos))
938
997
  PYX
939
- )" = 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" \
940
999
  || { echo "FAIL espejo de phase en dual"; fail=1; }
941
1000
 
942
1001
  # dual: archive también hace catch-up por el espejo (cadena red→…→pr antes del archivado).
@@ -950,12 +1009,14 @@ kinds=[]
950
1009
  for line in open(sys.argv[1]):
951
1010
  c=json.loads(line)
952
1011
  if c["path"]=="/projects/p-1/mirror/transitions":
953
- b=json.loads(c["body"])
954
- kinds.append((b.get("kind"), b["payload"].get("to")))
955
- 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"]]
956
1017
  print("SI" if kinds==esperado else "NO:"+repr(kinds))
957
1018
  PYX
958
- )" = 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" \
959
1020
  || { echo "FAIL cadena del archive dual"; fail=1; }
960
1021
 
961
1022
  # Restaura la ruta y la caché al estado que esperan los bloques siguientes (sl-7, fase tdd).
@@ -982,12 +1043,16 @@ for line in open(sys.argv[1]):
982
1043
  c=json.loads(line)
983
1044
  if c["path"]=="/projects/p-1/mirror/transitions":
984
1045
  b=json.loads(c["body"])
985
- # [#44] el espejo también usa el nombre del catálogo (TIPOS_DE_ESPEJO del hub
986
- # enumera gate_verdict, no verdict_reported).
987
- 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"])
988
1053
  print("SI" if ok else "NO")
989
1054
  PYX
990
- )" = 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; }
991
1056
  [ ! -s "$PROJ/.claude/state/outbox" ] || true
992
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)"
993
1058
 
@@ -999,12 +1064,15 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
999
1064
 
1000
1065
  # ══════════════ hooks/build/dual-compare.sh [Task E] ══════════════
1001
1066
  # build-state.json de arriba sigue en pie: EP-009, phase tdd, gates {dor:true}.
1002
- # En dual, escalate NO encola: espeja por POST /projects/{id}/mirror/transitions (el
1003
- # 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í.
1004
1073
  DC="$ROOT/hooks/build/dual-compare.sh"
1005
1074
  mirror_escalated() {
1006
- # SI/NO: ¿calls-ga.jsonl tiene un POST a mirror/transitions con kind=slice_escalated?
1007
- # [#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.
1008
1076
  python3 - "$TMP/calls-ga.jsonl" <<'PYX'
1009
1077
  import json,sys
1010
1078
  found=False
@@ -1014,9 +1082,9 @@ for line in open(sys.argv[1]):
1014
1082
  except Exception:
1015
1083
  continue
1016
1084
  if c.get("path")=="/projects/p-1/mirror/transitions":
1017
- b=json.loads(c["body"])
1018
- if b.get("kind")=="slice_escalated":
1019
- found=True
1085
+ for t in json.loads(c["body"]).get("transitions") or []:
1086
+ if t.get("event_type")=="slice_escalated":
1087
+ found=True
1020
1088
  print("SI" if found else "NO")
1021
1089
  PYX
1022
1090
  }
@@ -1053,9 +1121,10 @@ json.dump(d,open(p,"w"))
1053
1121
  PYX
1054
1122
  : > "$TMP/calls-ga.jsonl"
1055
1123
  rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
1056
- [ "$rc" = 0 ] && [ "$(mirror_escalated)" = SI ] && echo "$err" | grep -qi "discrepancia" \
1124
+ [ "$rc" = 0 ] && [ "$(mirror_escalated)" = NO ] && echo "$err" | grep -qi "discrepancia" \
1057
1125
  && echo "$err" | grep -q "EP-009" && echo "$err" | grep -q "EP-999" \
1058
- && 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; }
1059
1128
 
1060
1129
  # dual, gate DISTINTO (mismo epic/fase): también cuenta como discrepancia.
1061
1130
  python3 - "$TMP/routes-ga.json" <<'PYX'
@@ -1065,8 +1134,10 @@ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9",
1065
1134
  json.dump(d,open(p,"w"))
1066
1135
  PYX
1067
1136
  : > "$TMP/calls-ga.jsonl"
1068
- rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
1069
- [ "$(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; }
1070
1141
 
1071
1142
  # dual, runtime SIN slice activo (el fichero sí tiene): también es discrepancia.
1072
1143
  python3 - "$TMP/routes-ga.json" <<'PYX'
@@ -1076,8 +1147,10 @@ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":Tr
1076
1147
  json.dump(d,open(p,"w"))
1077
1148
  PYX
1078
1149
  : > "$TMP/calls-ga.jsonl"
1079
- rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
1080
- [ "$(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; }
1081
1154
 
1082
1155
  # fail-open: espejo caído (runtime inalcanzable) -> nunca lanza, nunca aborta (rc 0 siempre).
1083
1156
  python3 - "$TMP/routes-ga.json" <<'PYX'
@@ -1999,6 +2072,317 @@ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
1999
2072
  [ $rc -eq 4 ] && echo "OK epic-writeback rc 4 sin fichero de epicas" \
2000
2073
  || { echo "FAIL epic-writeback sin fichero (rc $rc): $out"; fail=1; }
2001
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
+
2002
2386
  # ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
2003
2387
  GB="$ROOT/scripts/lib/graph-bundle.py"
2004
2388
  cat > "$TMP/epics.json" <<'JSON'
@@ -2153,6 +2537,66 @@ grep -q "EP-020 tiene sección" "$TMP/gb10.err" && grep -q "EP-021 no aparece co
2153
2537
 
2154
2538
  # 4-ter) Las dos banderas juntas son un error explícito (ignorar una en silencio es el
2155
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
+
2156
2600
  echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/epicas.md" --print-deps "$TMP/docs/epicas.md" > /dev/null 2> "$TMP/gb11.err"
2157
2601
  [ "$?" = 1 ] && grep -qi "excluyentes" "$TMP/gb11.err" \
2158
2602
  && echo "OK --from-docs y --print-deps juntas -> rc 1 explícito" || { echo "FAIL banderas excluyentes"; fail=1; }
@@ -2171,7 +2615,7 @@ PORT_GV="$(freeport)"
2171
2615
  cat > "$TMP/routes-gv.json" <<JSON
2172
2616
  {"GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": {}, "nudges": [],
2173
2617
  "context": {"manifest_hash": "sha256:v2", "version": 9, "graph_version": 45}}},
2174
- "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}}}}
2175
2619
  JSON
2176
2620
  : > "$TMP/calls-gv.jsonl"
2177
2621
  python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GV" "$TMP/routes-gv.json" "$TMP/calls-gv.jsonl" &
@@ -2231,6 +2675,51 @@ PY
2231
2675
  [ "$has" = NO ] && [ $rc -eq 7 ] && echo "OK sin hub que versione, el claim no manda nada nuevo" \
2232
2676
  || { echo "FAIL compatibilidad hacia atras del claim (has=$has rc=$rc)"; fail=1; }
2233
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
+
2234
2723
  kill "$STUB_GV" 2>/dev/null; wait "$STUB_GV" 2>/dev/null
2235
2724
 
2236
2725
  exit $fail