@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.
@@ -5,9 +5,10 @@
5
5
  #
6
6
  # uso: slice-ops.sh <subcomando> [opciones]
7
7
  # subcomandos IMPLEMENTADOS (13 del plan + phase, issue #52; + propose-epic/epic-status/
8
- # epic-writeback, issue #62): mode | claim | next-step | phase | gate | submit |
9
- # archive | wiring | progress | checkpoint | fact | propose-asset |
10
- # propose-epic | epic-status | epic-writeback | status | escalate.
8
+ # epic-writeback, issue #62; + graph-sync/graph-sync-status, EP-OR-17):
9
+ # mode | claim | next-step | phase | gate | submit | archive | wiring |
10
+ # progress | checkpoint | fact | propose-asset | propose-epic | epic-status |
11
+ # epic-writeback | graph-sync | graph-sync-status | status | escalate.
11
12
  #
12
13
  # Contrato con la prosa: cada subcomando devuelve un código de salida tipado
13
14
  # (0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo) y
@@ -46,6 +47,11 @@ uso: slice-ops.sh <subcomando> [opciones]
46
47
  epic-writeback [--id P] [--file F] escribe en epicas.md las épicas ya APROBADAS,
47
48
  con el código que asignó el hub (F debe estar
48
49
  bajo docs/03-backlog/: carve-out §9.2)
50
+ graph-sync --file epics.json [--dry-run] propone al hub el re-sync del grafo desde los
51
+ docs de discovery (el humano aprueba; con
52
+ --dry-run solo enseña el bundle, sin enviar)
53
+ graph-sync-status [--id P] veredicto del re-sync (QUEUED|PROPOSED|
54
+ APPROVED|REJECTED con el motivo del ADMIN)
49
55
  status [--no-refresh] informe local del agente
50
56
  escalate «razón» | --file F [--gate G] registra un bloqueo (la decisión es humana)
51
57
 
@@ -72,6 +78,78 @@ except Exception:
72
78
  ' "$2" 2>/dev/null
73
79
  }
74
80
 
81
+ # [EP-OR-17] Los motivos del 409 del claim, como los emite el hub DE VERDAD.
82
+ #
83
+ # Este `case` esperaba `drift`/`contention`/`context_unresolvable`/`cross_project` como valor de
84
+ # `reason` en la RAÍZ de la respuesta. Ninguno de esos literales existe en el backend del hub:
85
+ # un HTTPException de FastAPI anida SIEMPRE bajo `detail`, y los cuatro casos llegan como
86
+ # • drift de contexto → detail dict con `manifest_hash` (el hash al que sincronizar)
87
+ # • contención → detail str «contención con otra escritura del contexto: reintenta…»
88
+ # • contexto irresoluble → detail str «…no hay trabajo sin contexto declarado»
89
+ # • lease ajeno/expirado → detail str «El lease está expirado, es de otro agente o no existe…»
90
+ # Todos caían en el `*)` y salían como rechazo duro: un drift —que se resuelve sincronizando y
91
+ # reintentando— dejaba al agente sin claim y sin saber por qué. La clasificación por FORMA (el
92
+ # dict con manifest_hash) manda sobre la clasificación por texto, que solo se usa donde el hub
93
+ # construye el mensaje en un único sitio. Se conserva la lectura plana por compatibilidad.
94
+
95
+ # _claim_409_kind <json> — clasifica el 409: drift|contention|context_unresolvable|
96
+ # cross_project|"" (desconocido).
97
+ _claim_409_kind() {
98
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
99
+ printf '%s' "$1" | python3 -c '
100
+ import json,sys
101
+ try:
102
+ d=json.load(sys.stdin)
103
+ except Exception:
104
+ print(""); raise SystemExit(0)
105
+ if not isinstance(d,dict):
106
+ print(""); raise SystemExit(0)
107
+ det=d.get("detail")
108
+ # Compatibilidad: una instancia que sí nombrara el motivo en un campo manda sobre el texto.
109
+ for fuente in (det if isinstance(det,dict) else {}, d):
110
+ marca=fuente.get("reason") or fuente.get("error")
111
+ if isinstance(marca,str) and marca:
112
+ print(marca); raise SystemExit(0)
113
+ # El drift se reconoce por FORMA, no por texto: el borde del hub garantiza el `manifest_hash`
114
+ # en el cuerpo precisamente para que el cliente sepa a qué contexto sincronizar.
115
+ if isinstance(det,dict) and det.get("manifest_hash"):
116
+ print("drift"); raise SystemExit(0)
117
+ texto=det if isinstance(det,str) else (det.get("message","") if isinstance(det,dict) else "")
118
+ t=str(texto).lower()
119
+ if "contenci" in t:
120
+ print("contention")
121
+ elif "no hay trabajo sin contexto" in t:
122
+ print("context_unresolvable")
123
+ elif "otro agente" in t or "otro proyecto" in t:
124
+ print("cross_project")
125
+ else:
126
+ print("")
127
+ ' 2>/dev/null
128
+ }
129
+
130
+ # _claim_409_text <json> — el motivo del hub en prosa, para enseñárselo al humano sin volcarle
131
+ # el JSON crudo encima. Cae al cuerpo entero si no hay nada mejor.
132
+ _claim_409_text() {
133
+ command -v python3 >/dev/null 2>&1 || { printf '%s' "$1"; return; }
134
+ printf '%s' "$1" | python3 -c '
135
+ import json,sys
136
+ crudo=sys.stdin.read()
137
+ try:
138
+ d=json.loads(crudo)
139
+ except Exception:
140
+ print(crudo.strip()); raise SystemExit(0)
141
+ if not isinstance(d,dict):
142
+ print(crudo.strip()); raise SystemExit(0)
143
+ det=d.get("detail")
144
+ if isinstance(det,str) and det:
145
+ print(det)
146
+ elif isinstance(det,dict):
147
+ print(det.get("message") or json.dumps(det,ensure_ascii=False))
148
+ else:
149
+ print(crudo.strip())
150
+ ' 2>/dev/null
151
+ }
152
+
75
153
  # _claim_render <json> — resumen compacto del slice reclamado (lo lee el humano y el modelo).
76
154
  _claim_render() {
77
155
  command -v python3 >/dev/null 2>&1 || { echo "$1"; return; }
@@ -127,31 +205,33 @@ _claim_once() {
127
205
  echo "NO_WORK: el runtime no tiene trabajo disponible para este agente"
128
206
  return $OPS_RC_NO_WORK ;;
129
207
  409)
130
- reason="$(_claim_field "$resp" reason)"
131
- [ -n "$reason" ] || reason="$(_claim_field "$resp" error)"
208
+ # [EP-OR-17] El grafo rancio se comprueba ANTES del `case`, con el helper del cliente:
209
+ # conoce la forma REAL del hub (`detail.error = graph_version_stale`) además de la plana
210
+ # que este sitio leía a mano y que el hub no emite en ninguna parte.
211
+ local server_gv
212
+ if server_gv="$(runtime_stale_graph_version "$resp")"; then
213
+ # [#63] No es un error: es la señal de que operamos contra una foto vieja del
214
+ # backlog. Se anota el desfase (lo muestra `status`) y se pide UN reintento con la
215
+ # proyección refrescada. Mismo patrón que el 409 del lease en hb_renew.
216
+ runtime_graph_note_stale "$gv" "$server_gv"
217
+ echo "⚠ 409 (graph_version_stale): el grafo local (v${gv:-?}) va por detrás del hub (v${server_gv:-?})" >&2
218
+ return 21
219
+ fi
220
+ reason="$(_claim_409_kind "$resp")"
132
221
  case "$reason" in
133
- stale_graph)
134
- # [#63] No es un error: es la señal de que operamos contra una foto vieja del
135
- # backlog. Se anota el desfase (lo muestra `status`) y se pide UN reintento con la
136
- # proyección refrescada. Mismo patrón que el 409 del lease en hb_renew.
137
- local server_gv
138
- server_gv="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("graph_version") or "")' 2>/dev/null)"
139
- runtime_graph_note_stale "$gv" "$server_gv"
140
- echo "⚠ 409 (stale_graph): el grafo local (v${gv:-?}) va por detrás del hub (v${server_gv:-?})" >&2
141
- return 21 ;;
142
222
  drift|contention)
143
- echo "⚠ 409 ($reason): reintentable" >&2
223
+ echo "⚠ 409 ($reason): reintentable — $(_claim_409_text "$resp")" >&2
144
224
  return 20 ;;
145
225
  context_unresolvable)
146
- echo "⛔ 409 contexto irresoluble — NO reintentar: $resp" >&2
226
+ echo "⛔ 409 contexto irresoluble — NO reintentar: $(_claim_409_text "$resp")" >&2
147
227
  echo " Resuélvelo en el hub (propuesta → publicación) y vuelve a reclamar." >&2
148
228
  return $OPS_RC_REJECTED ;;
149
229
  cross_project)
150
- echo "⛔ 409 cruce de proyecto (queda auditado en el servidor): $resp" >&2
230
+ echo "⛔ 409 cruce de proyecto (queda auditado en el servidor): $(_claim_409_text "$resp")" >&2
151
231
  echo " Este clon está registrado contra otro proyecto: revisa runtime.credentials." >&2
152
232
  return $OPS_RC_REJECTED ;;
153
233
  *)
154
- echo "⛔ 409 sin razón reconocida en el cuerpo: $resp" >&2
234
+ echo "⛔ 409 del hub: $(_claim_409_text "$resp")" >&2
155
235
  return $OPS_RC_REJECTED ;;
156
236
  esac ;;
157
237
  401|403)
@@ -322,11 +402,15 @@ cmd_fact() {
322
402
  # escribible, python3 ausente u otro fallo local) — el caller decide qué avisar y nunca debe
323
403
  # reportar éxito en ese caso (fail-open sobre el trabajo local, nunca sobre el reporte).
324
404
  _ops_emit_event() {
325
- local tipo="$1" payload="$2" sid="${3:-}" pfile mode
405
+ local tipo="$1" payload="$2" sid="${3:-}" pfile mode epic
326
406
  mode="$(ops_mode)"
327
407
  pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
328
408
  if [ "$mode" = "dual" ]; then
329
- ops_mirror "$tipo" "$pfile" || echo "⚠ «${tipo}» no espejado; el comparador dual lo marcará" >&2
409
+ # [EP-OR-17] El `epic_code` va en el SOBRE del espejo, que es donde el hub lo lee. Muchos de
410
+ # los tipos que pasan por aquí (hechos de proyecto, archivado, escalada) no son espejables:
411
+ # `ops_mirror` los corta en local con rc 8 antes de gastar una llamada.
412
+ epic="$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)"
413
+ ops_mirror "$tipo" "$pfile" "$epic" || echo "⚠ «${tipo}» no espejado; el comparador dual lo marcará" >&2
330
414
  rm -f "$pfile"
331
415
  return $OPS_RC_OK
332
416
  fi
@@ -485,8 +569,29 @@ _epic_draft_normalize() {
485
569
  # el motivo del rc 2 (validación) o el aviso de `epic_code` ignorado; silenciarlo aquí
486
570
  # los perdería a los dos.
487
571
  python3 - "$f" <<'PY'
488
- import json,sys
489
- CAPAS={"foundational","business","technical"}
572
+ import json,re,sys
573
+ # [#70] El vocabulario del BORRADOR (lo que escribe el agente) y el del CONTRATO del hub
574
+ # (`EpicProposalIn`) no son el mismo: el borrador habla en minúsculas y conoce `technical`;
575
+ # el hub solo acepta el literal FOUNDATIONAL|BUSINESS. La traducción vive aquí, en el borde,
576
+ # porque rechazar `technical` sería castigar al agente por una distinción que el grafo no
577
+ # hace: para el hub una épica técnica es de negocio.
578
+ CAPAS={"foundational":"FOUNDATIONAL","business":"BUSINESS","technical":"BUSINESS"}
579
+
580
+ def escenarios(txt):
581
+ """Criterios en prosa -> `acceptance[]` del contrato: un escenario Dado/Cuando/Entonces
582
+ por entrada. Se parte por líneas y, dentro de cada una, por el `. Dado ` que separa dos
583
+ escenarios seguidos en un mismo párrafo."""
584
+ out=[]
585
+ for linea in str(txt).splitlines():
586
+ linea=linea.strip().lstrip("-*•").strip()
587
+ if not linea:
588
+ continue
589
+ for trozo in re.split(r"\.\s+(?=Dado\s)", linea):
590
+ trozo=trozo.strip()
591
+ if trozo:
592
+ out.append(trozo[:2000])
593
+ return out[:20]
594
+
490
595
  try:
491
596
  d=json.load(open(sys.argv[1]))
492
597
  except Exception:
@@ -499,29 +604,77 @@ if not title:
499
604
  print("⛔ el borrador no trae `title` (título de la épica)", file=sys.stderr); raise SystemExit(2)
500
605
  if not objective:
501
606
  print("⛔ el borrador no trae `objective` (objetivo de la épica)", file=sys.stderr); raise SystemExit(2)
502
- layer=d.get("layer") or "business"
607
+ layer=str(d.get("layer") or "business").strip().lower()
503
608
  if layer not in CAPAS:
504
609
  print("⛔ `layer` debe ser uno de: %s" % ", ".join(sorted(CAPAS)), file=sys.stderr); raise SystemExit(2)
505
610
  ignorados=[k for k in ("epic_code","code","id") if d.get(k)]
506
611
  stories=[]
507
612
  for s in d.get("stories") or []:
508
613
  if isinstance(s,str):
509
- stories.append({"title":s})
614
+ stories.append({"title":s[:300]})
510
615
  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]
616
+ st={"title":s["title"].strip()[:300]}
617
+ if s.get("description"):
618
+ st["description"]=str(s["description"])[:2000]
619
+ # La `acceptance` explícita del borrador manda; si no, se derivan de los criterios en
620
+ # prosa. `acceptance_criteria` NO viaja: el hub descarta el campo en el borde
621
+ # (`extra="ignore"`) y la consola enseñaba las historias sin un solo criterio.
622
+ acc=s.get("acceptance")
623
+ if isinstance(acc,list):
624
+ acc=[str(x).strip()[:2000] for x in acc if str(x).strip()][:20]
625
+ elif s.get("acceptance_criteria"):
626
+ acc=escenarios(s["acceptance_criteria"])
627
+ else:
628
+ acc=[]
629
+ if acc:
630
+ st["acceptance"]=acc
514
631
  stories.append(st)
515
- out={"title":title[:300],"objective":objective[:2000],"layer":layer,
632
+ out={"title":title[:300],"objective":objective[:2000],"layer":CAPAS[layer],
516
633
  "files_scope":[str(x) for x in (d.get("files_scope") or []) if isinstance(x,(str,int))],
517
634
  "depends_on":[str(x) for x in (d.get("depends_on") or []) if isinstance(x,(str,int))],
518
- "stories":stories,"origin":"harness-draft"}
635
+ "stories":stories}
519
636
  if ignorados:
520
637
  print("IGNORADOS %s" % ",".join(ignorados), file=sys.stderr)
521
638
  print(json.dumps(out,ensure_ascii=False))
522
639
  PY
523
640
  }
524
641
 
642
+ # [#70] `EpicProposalOut` del contrato nombra `id` al identificador de la propuesta (el
643
+ # borrador de hub#113 lo llamaba `proposal_id`). Sin esto la terminal decía «sin identificador
644
+ # en la respuesta» y el ledger guardaba "" — `epic-status` se quedaba sin a quién preguntar.
645
+ # Se acepta el nombre viejo como respaldo: un hub anterior al contrato sigue funcionando.
646
+ _epic_proposal_id() {
647
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
648
+ printf '%s' "$1" | python3 -c '
649
+ import json,sys
650
+ try:
651
+ d=json.load(sys.stdin)
652
+ r=(d.get("response") if isinstance(d.get("response"),dict) else d) or {}
653
+ print(r.get("id") or r.get("proposal_id") or "")
654
+ except Exception:
655
+ print("")
656
+ ' 2>/dev/null
657
+ }
658
+
659
+ # _epic_from_response <respuesta> — la épica aprobada como objeto para el ledger. El contrato
660
+ # la devuelve en la RAÍZ de `EpicProposalOut`; el borrador viejo la anidaba en `epic`.
661
+ _epic_from_response() {
662
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
663
+ printf '%s' "$1" | python3 -c '
664
+ import json,sys
665
+ try:
666
+ d=json.load(sys.stdin)
667
+ except Exception:
668
+ print("{}"); raise SystemExit(0)
669
+ if not isinstance(d,dict):
670
+ print("{}"); raise SystemExit(0)
671
+ e=d.get("epic")
672
+ if not isinstance(e,dict):
673
+ e={k:d[k] for k in ("title","objective","layer","stories","files_scope","depends_on") if k in d}
674
+ print(json.dumps(e,ensure_ascii=False))
675
+ ' 2>/dev/null || echo "{}"
676
+ }
677
+
525
678
  cmd_propose_epic() {
526
679
  local file="" mode pid norm errf rc cid pfile ack proposal_id reason
527
680
  while [ $# -gt 0 ]; do
@@ -590,7 +743,7 @@ cmd_propose_epic() {
590
743
  runtime_dispatch_outbox >/dev/null 2>&1
591
744
  ack="$(ops_epic_ack_read "$cid")"
592
745
  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)"
746
+ proposal_id="$(_epic_proposal_id "$ack")"
594
747
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$proposal_id")}"
595
748
  echo "propuesta enviada: ${proposal_id:-sin identificador en la respuesta}"
596
749
  echo "Queda en PROPOSED: NO es reclamable hasta que un humano la apruebe en la consola del hub."
@@ -662,7 +815,7 @@ cmd_epic_status() {
662
815
  if [ "$st" = "QUEUED" ] && [ -n "$cid" ]; then
663
816
  ack="$(ops_epic_ack_read "$cid")"
664
817
  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)"
818
+ pidv="$(_epic_proposal_id "$ack")"
666
819
  st=PROPOSED
667
820
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$pidv")}"
668
821
  else
@@ -686,13 +839,18 @@ cmd_epic_status() {
686
839
  case "$st" in
687
840
  APPROVED)
688
841
  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)"
842
+ # [#70] El código lo asigna el hub en `assigned_code` (EP-OR-15); `epic_code` era
843
+ # el nombre del borrador y se conserva como respaldo.
844
+ code="$(_claim_field "$resp" assigned_code)"
845
+ [ -n "$code" ] || code="$(_claim_field "$resp" epic_code)"
846
+ epic="$(_epic_from_response "$resp")"
691
847
  [ -n "$epic" ] || epic="{}"
692
848
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"APPROVED\",\"epic_code\":$(ops_json_str "$code"),\"epic\":$epic}"
693
849
  echo "$pidv APPROVED $code — escríbela con \`slice-ops.sh epic-writeback\`" ;;
694
850
  REJECTED)
695
- local why; why="$(_claim_field "$resp" reason)"
851
+ # [#70] El motivo del rechazo humano viaja en `reject_reason` (EP-OR-15).
852
+ local why; why="$(_claim_field "$resp" reject_reason)"
853
+ [ -n "$why" ] || why="$(_claim_field "$resp" reason)"
696
854
  ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"REJECTED\",\"reason\":$(ops_json_str "$why")}"
697
855
  echo "$pidv REJECTED ${why:-sin motivo registrado}" ;;
698
856
  *)
@@ -871,6 +1029,294 @@ PY
871
1029
  return $OPS_RC_OK
872
1030
  }
873
1031
 
1032
+ # ── graph-sync ───────────────────────────────────────────────────────────────────────
1033
+ # [EP-OR-17] Hasta ahora el grafo del hub dejaba de evolucionar tras el import inicial de
1034
+ # ADMIN: las épicas que discovery añadía después no tenían por dónde entrar, y TODOS los
1035
+ # eventos de un slice cuya épica no está en el grafo rebotan con «la épica no existe en el
1036
+ # grafo del proyecto». Este carril lo cierra sin regalarle al agente la potestad de mutar el
1037
+ # grafo: el arnés PROPONE el bundle, el hub calcula el delta y un humano aprueba en la consola.
1038
+
1039
+ # _graph_sync_bundle <fichero-epics> [from_docs] — imprime el bundle proyectado al contrato,
1040
+ # o "" con rc 2 (no cumple el contrato) / rc 5 (falta infraestructura local).
1041
+ _graph_sync_bundle() {
1042
+ local f="$1" docs="${2:-}" gb
1043
+ gb="$HERE/../../scripts/lib/graph-bundle.py"
1044
+ [ -f "$gb" ] || gb="$(config_root)/.claude/scripts/lib/graph-bundle.py"
1045
+ if [ ! -f "$gb" ]; then
1046
+ echo "⛔ no se encuentra graph-bundle.py: reinstala con \`trycore-build update\`" >&2
1047
+ return $OPS_RC_OFFLINE
1048
+ fi
1049
+ # [Importante] Sin python3 no es "bundle inválido" (rc 2 de uso): es una degradación de
1050
+ # infraestructura. Se anuncia con esas palabras para que no se confunda con un backlog mal
1051
+ # escrito, que es lo que el agente iría a arreglar.
1052
+ command -v python3 >/dev/null 2>&1 || {
1053
+ echo "⛔ sin python3: no se puede construir el bundle en local" >&2
1054
+ return $OPS_RC_OFFLINE
1055
+ }
1056
+ if [ -n "$docs" ]; then
1057
+ python3 "$gb" --for-sync --from-docs "$docs" < "$f"
1058
+ else
1059
+ python3 "$gb" --for-sync < "$f"
1060
+ fi
1061
+ }
1062
+
1063
+ # _graph_sync_out <respuesta> <clave> — escalar de la respuesta del hub ("" si falta). Acepta
1064
+ # tanto el cuerpo desnudo como el sobre `{"response": …}` con el que el carril directo guarda
1065
+ # los acks, que es de donde lo lee este comando.
1066
+ _graph_sync_out() {
1067
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
1068
+ printf '%s' "$1" | python3 -c '
1069
+ import json,sys
1070
+ try:
1071
+ d=json.load(sys.stdin)
1072
+ r=(d.get("response") if isinstance(d.get("response"),dict) else d) or {}
1073
+ v=r.get(sys.argv[1])
1074
+ print("" if v is None else (v if isinstance(v,str) else json.dumps(v,ensure_ascii=False)))
1075
+ except Exception:
1076
+ print("")
1077
+ ' "$2" 2>/dev/null
1078
+ }
1079
+
1080
+ # _graph_sync_delta_render <delta-json> — resumen legible del delta que devuelve el hub. Es lo
1081
+ # que el humano va a aprobar: enseñarlo aquí evita que el agente proponga a ciegas.
1082
+ _graph_sync_delta_render() {
1083
+ command -v python3 >/dev/null 2>&1 || return 0
1084
+ printf '%s' "$1" | python3 -c '
1085
+ import json,sys
1086
+ try:
1087
+ d=json.load(sys.stdin)
1088
+ except Exception:
1089
+ raise SystemExit(0)
1090
+ if not isinstance(d,dict):
1091
+ raise SystemExit(0)
1092
+ print("delta propuesto: %d épicas nuevas · %d épicas con cambios · %d historias nuevas · %d historias con cambios"
1093
+ % (len(d.get("epics_added") or []), len(d.get("epics_changed") or []),
1094
+ len(d.get("stories_added") or []), len(d.get("stories_changed") or [])))
1095
+ for e in (d.get("epics_added") or [])[:10]:
1096
+ print(" + %s %s" % (e.get("code",""), e.get("title","")))
1097
+ for e in (d.get("epics_changed") or [])[:10]:
1098
+ print(" ~ %s (%s)" % (e.get("code",""), ", ".join(sorted((e.get("fields") or {}).keys()))))
1099
+ ' 2>/dev/null
1100
+ }
1101
+
1102
+ cmd_graph_sync() {
1103
+ local file="" docs="" dry=0 mode pid bundle rc cid pfile n_epicas
1104
+ while [ $# -gt 0 ]; do
1105
+ case "$1" in
1106
+ --file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
1107
+ --from-docs) docs="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
1108
+ --dry-run) dry=1; shift ;;
1109
+ *) shift ;;
1110
+ esac
1111
+ done
1112
+ if [ -z "$file" ]; then
1113
+ echo "uso: slice-ops.sh graph-sync --file epics.json [--from-docs docs/03-backlog/epicas.md] [--dry-run]" >&2
1114
+ echo " epics.json: {\"epics\":[{code,title,layer,files_scope[],depends_on[],stories[]}]}" >&2
1115
+ echo " lo escribes leyendo docs/03-backlog/epicas.md y las HU-*.md (igual que en /build:onboard)." >&2
1116
+ return $OPS_RC_USAGE
1117
+ fi
1118
+ [ -f "$file" ] || { echo "⛔ no existe el fichero de épicas: $file" >&2; return $OPS_RC_USAGE; }
1119
+ [ -z "$docs" ] || [ -f "$docs" ] || { echo "⛔ no existe el documento de épicas: $docs" >&2; return $OPS_RC_USAGE; }
1120
+
1121
+ # El bundle se construye ANTES de mirar el modo: `--dry-run` tiene que servir también en
1122
+ # legacy y dual, que es donde más falta hace revisar el grafo antes de registrarse.
1123
+ bundle="$(_graph_sync_bundle "$file" "$docs")"
1124
+ rc=$?
1125
+ if [ $rc -ne 0 ] || [ -z "$bundle" ]; then
1126
+ [ $rc -eq $OPS_RC_OFFLINE ] && return $OPS_RC_OFFLINE
1127
+ echo "⛔ el bundle no se envía: corrige los docs de discovery y vuelve a intentarlo." >&2
1128
+ return $OPS_RC_USAGE
1129
+ fi
1130
+ if [ "$dry" = 1 ]; then
1131
+ printf '%s\n' "$bundle"
1132
+ echo "--dry-run: nada se ha enviado. Quita la opción para proponer el re-sync al hub." >&2
1133
+ return $OPS_RC_OK
1134
+ fi
1135
+
1136
+ mode="$(ops_mode)"
1137
+ if [ "$mode" != "runtime" ]; then
1138
+ echo "${mode}: el re-sync del grafo solo opera en modo \`runtime\`."
1139
+ echo "Con el fichero local como fuente de verdad, el grafo vive en los documentos y entra"
1140
+ echo "al hub por el import de admin (\`/build:onboard\`). Usa --dry-run para revisarlo."
1141
+ return $OPS_RC_LEGACY
1142
+ fi
1143
+ pid="$(runtime_field project_id)"
1144
+ [ -n "$pid" ] || { echo "⛔ sin project_id en runtime.credentials: corre \`trycore-build init\`" >&2; return $OPS_RC_REJECTED; }
1145
+
1146
+ cid="$(ops_graph_sync_cid)"
1147
+ # El cid es determinista, luego REUTILIZABLE: si el ledger se pierde o se limpia, el
1148
+ # contador vuelve atrás y el rastro local de un envío anterior con ese mismo cid (su ack o
1149
+ # su apartado) seguiría en disco. Leerlo haría que este comando reportara el resultado de
1150
+ # una propuesta que NO acaba de enviar. Se retira antes de encolar: el hub sigue
1151
+ # deduplicando por cid igualmente, así que borrar el rastro local no duplica nada.
1152
+ rm -f "$(runtime_outbox_dir)/acks/${cid}.json" 2>/dev/null
1153
+ rm -f "$(runtime_outbox_dir)/rejected/"*"${cid}.json" 2>/dev/null
1154
+ pfile="$(ops_tmpjson "{\"bundle\":$bundle}")" || return $OPS_RC_OFFLINE
1155
+ # El `1` pide el estampado de `graph_version` AL DESPACHAR: un re-sync que espera en la cola
1156
+ # offline debe salir con la versión vigente en ese momento, no con la de ayer. El `$cid` fija
1157
+ # la identidad: el reintento tras un 409 la reusa y el hub deduplica.
1158
+ cid="$(runtime_enqueue_direct graph_sync_proposed POST "$(ops_path_graph_sync_proposals "$pid")" "$pfile" 1 "$cid")"
1159
+ rm -f "$pfile"
1160
+ if [ -z "$cid" ]; then
1161
+ echo "⛔ no se pudo encolar el re-sync (outbox no escribible): reintenta." >&2
1162
+ return $OPS_RC_OFFLINE
1163
+ fi
1164
+ n_epicas="$(printf '%s' "$bundle" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["epics"]))' 2>/dev/null || echo 0)"
1165
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"QUEUED\",\"proposal_id\":null,\"epics\":$n_epicas}"
1166
+
1167
+ _graph_sync_despachar "$cid"
1168
+ }
1169
+
1170
+ # _graph_sync_despachar <cid> — despacha, interpreta el resultado y, ante un grafo rancio,
1171
+ # refresca la proyección y reintenta UNA vez.
1172
+ #
1173
+ # El reintento vive AQUÍ y no en el despachador porque `runtime-client.sh` es agnóstico al
1174
+ # dominio: no sabe qué es un bundle ni cuándo vale la pena volver a intentarlo. Y re-estampar
1175
+ # con la versión nueva ES «recalcular el bundle contra la versión que devuelve el hub»: el
1176
+ # bundle no depende de la versión del grafo, solo el sello que lo acompaña.
1177
+ _graph_sync_despachar() {
1178
+ local cid="$1" intento=1 ack reason id_prop delta
1179
+ while :; do
1180
+ runtime_dispatch_outbox >/dev/null 2>&1
1181
+ ack="$(ops_epic_ack_read "$cid")"
1182
+ if [ -n "$ack" ]; then
1183
+ if [ "$(_graph_sync_out "$ack" empty_delta)" = "true" ]; then
1184
+ # HU-OR-72 E2: no es un error ni una propuesta — el bundle coincide con el grafo
1185
+ # vigente. Se cierra la entrada del ledger para que `graph-sync-status` no vaya a
1186
+ # preguntar por una propuesta que el hub nunca creó.
1187
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"EMPTY_DELTA\"}"
1188
+ echo "el grafo del hub ya está al día: no hay cambios que proponer."
1189
+ return $OPS_RC_OK
1190
+ fi
1191
+ id_prop="$(_graph_sync_out "$ack" id)"
1192
+ delta="$(_graph_sync_out "$ack" delta)"
1193
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$id_prop")}"
1194
+ echo "re-sync propuesto: ${id_prop:-sin identificador en la respuesta}"
1195
+ [ -n "$delta" ] && _graph_sync_delta_render "$delta"
1196
+ echo "Queda en PROPOSED: NADA se ha aplicado al grafo hasta que un humano lo apruebe en"
1197
+ echo "la consola del hub. Sigue el veredicto con \`slice-ops.sh graph-sync-status\`."
1198
+ return $OPS_RC_OK
1199
+ fi
1200
+ reason="$(ops_epic_rejected_reason "$cid")"
1201
+ if [ -n "$reason" ]; then
1202
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"FAILED\",\"reason\":$(ops_json_str "$reason")}"
1203
+ case "$reason" in
1204
+ *"HTTP 404"*|*404*)
1205
+ # Anti-oráculo (HU-OR-72 E4): el hub responde lo MISMO para «no existe» y «no es
1206
+ # tuyo». Afirmar una de las dos causas mandaría a buscar el problema equivocado.
1207
+ echo "⛔ el hub respondió 404. Son dos causas indistinguibles POR DISEÑO: o el proyecto" >&2
1208
+ echo " no existe, o no es el tuyo (el hub no confirma cuál para no filtrar proyectos" >&2
1209
+ echo " ajenos). Verifica el project_id de runtime.credentials y que el hub tenga" >&2
1210
+ echo " la superficie de re-sync (EP-OR-17)." >&2 ;;
1211
+ *) echo "⛔ el hub rechazó el re-sync: $reason" >&2 ;;
1212
+ esac
1213
+ return $OPS_RC_REJECTED
1214
+ fi
1215
+ # Sigue en la cola. Si fue por grafo rancio, el despachador dejó el marcador de desfase: se
1216
+ # refresca la proyección y se reintenta UNA vez con la versión nueva.
1217
+ if [ "$intento" = 1 ] && [ -n "$(runtime_graph_status_read)" ] && [ "$(runtime_graph_status_read)" != "{}" ]; then
1218
+ intento=2
1219
+ echo "⚠ el hub dice que nuestro grafo va rancio: se refresca el contexto y se reintenta una vez." >&2
1220
+ agent_context_fetch_and_cache >/dev/null 2>&1 || \
1221
+ echo "⚠ no se pudo refrescar /agent/context: el reintento sale con la versión que haya" >&2
1222
+ # El despacho lleva mutex y estado de backoff: sin limpiarlo, el segundo intento cedería
1223
+ # el turno y el reintento no ocurriría en este proceso.
1224
+ rm -f "$(runtime_outbox_dir)/.dispatch-state.json" 2>/dev/null
1225
+ continue
1226
+ fi
1227
+ echo "re-sync encolado: $cid"
1228
+ echo "PENDIENTE: se despachará solo (el daemon de heartbeat lo lleva). Sin conexión aún, o el"
1229
+ echo "daemon estaba drenando la cola en este instante. \`slice-ops.sh graph-sync-status\` lo sigue."
1230
+ return $OPS_RC_OFFLINE
1231
+ done
1232
+ }
1233
+
1234
+ # ── graph-sync-status ────────────────────────────────────────────────────────────────
1235
+ # [EP-OR-17] El motivo del rechazo lo escribe el ADMIN precisamente para que lo lea la terminal
1236
+ # que propuso: sin este subcomando el veredicto humano se queda en la consola del hub y el
1237
+ # agente vuelve a proponer exactamente lo mismo. Informativo: rc 0 siempre en runtime, pase lo
1238
+ # que pase — es una consulta, y fallar aquí no arregla nada.
1239
+ cmd_graph_sync_status() {
1240
+ local only="" mode pid entries n i cid pidv st resp status reason ack
1241
+ while [ $# -gt 0 ]; do
1242
+ case "$1" in
1243
+ --id) only="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
1244
+ *) shift ;;
1245
+ esac
1246
+ done
1247
+ mode="$(ops_mode)"
1248
+ if [ "$mode" != "runtime" ]; then
1249
+ echo "${mode}: no hay hub al que preguntar por el re-sync del grafo."
1250
+ return $OPS_RC_LEGACY
1251
+ fi
1252
+ pid="$(runtime_field project_id)"
1253
+ entries="$(ops_graph_sync_ledger_read)"
1254
+ n="$(printf '%s' "$entries" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("proposals") or []))' 2>/dev/null || echo 0)"
1255
+ case "$n" in ''|*[!0-9]*) n=0 ;; esac
1256
+ if [ "$n" = 0 ]; then
1257
+ echo "no hay ninguna propuesta de re-sync registrada en esta terminal."
1258
+ echo "Propón una con \`slice-ops.sh graph-sync --file epics.json\`."
1259
+ return $OPS_RC_OK
1260
+ fi
1261
+ i=0
1262
+ while [ "$i" -lt "$n" ]; do
1263
+ cid="$(printf '%s' "$entries" | python3 -c 'import json,sys; print(json.load(sys.stdin)["proposals"][int(sys.argv[1])].get("client_event_id") or "")' "$i" 2>/dev/null)"
1264
+ pidv="$(printf '%s' "$entries" | python3 -c 'import json,sys; print(json.load(sys.stdin)["proposals"][int(sys.argv[1])].get("proposal_id") or "")' "$i" 2>/dev/null)"
1265
+ st="$(printf '%s' "$entries" | python3 -c 'import json,sys; print(json.load(sys.stdin)["proposals"][int(sys.argv[1])].get("status") or "")' "$i" 2>/dev/null)"
1266
+ i=$((i + 1))
1267
+ [ -z "$only" ] || [ "$only" = "$pidv" ] || [ "$only" = "$cid" ] || continue
1268
+ if [ -z "$pidv" ]; then
1269
+ # Todavía sin identificador del hub: el estado lo dicta el carril directo local, no la red.
1270
+ ack="$(ops_epic_ack_read "$cid")"
1271
+ reason="$(ops_epic_rejected_reason "$cid")"
1272
+ if [ "$st" = "EMPTY_DELTA" ]; then
1273
+ echo "$cid EMPTY_DELTA (el grafo ya estaba al día: el hub no creó ninguna propuesta)"
1274
+ elif [ -n "$reason" ]; then
1275
+ echo "$cid FAILED $reason"
1276
+ elif [ -n "$ack" ]; then
1277
+ echo "$cid PROPOSED (ack recibido, sin identificador legible en la respuesta)"
1278
+ else
1279
+ echo "$cid ${st:-QUEUED} (pendiente de despacho: el daemon de heartbeat la lleva)"
1280
+ fi
1281
+ continue
1282
+ fi
1283
+ resp="$(runtime_get "$(ops_path_graph_sync_proposal "$pid" "$pidv")")"
1284
+ status="$(runtime_http_status)"
1285
+ case "$status" in
1286
+ 200) _graph_sync_status_render "$cid" "$pidv" "$resp" ;;
1287
+ 404)
1288
+ # HU-OR-73 E5: «no existe» y «es de otro proyecto» son el MISMO cuerpo, bit a bit.
1289
+ echo "$pidv DESCONOCIDA para el hub (o es de otro proyecto: son indistinguibles)" ;;
1290
+ *)
1291
+ echo "$pidv ${st:-?} (el hub no respondió, HTTP $status: se muestra el último estado conocido)" ;;
1292
+ esac
1293
+ done
1294
+ return $OPS_RC_OK
1295
+ }
1296
+
1297
+ # _graph_sync_status_render <cid> <proposal_id> <respuesta> — pinta el veredicto y lo persiste
1298
+ # en el ledger. La persistencia va aquí y no en el llamador porque el estado del hub SOLO se
1299
+ # conoce al leer la respuesta: guardarlo fuera obligaría a re-parsearla.
1300
+ _graph_sync_status_render() {
1301
+ local cid="$1" pidv="$2" resp="$3" st reason applied
1302
+ st="$(_graph_sync_out "$resp" status)"
1303
+ reason="$(_graph_sync_out "$resp" reject_reason)"
1304
+ applied="$(_graph_sync_out "$resp" graph_version_applied)"
1305
+ case "$st" in
1306
+ APPROVED)
1307
+ echo "$pidv APPROVED (grafo del hub en v${applied:-?})"
1308
+ echo " nada más que hacer: las historias nuevas entran solas al reparto del claim." ;;
1309
+ REJECTED)
1310
+ echo "$pidv REJECTED ${reason:-sin motivo registrado}"
1311
+ echo " corrige los docs de discovery y vuelve a proponer con \`graph-sync\`." ;;
1312
+ PROPOSED)
1313
+ echo "$pidv PROPOSED (esperando aprobación humana en la consola del hub)" ;;
1314
+ *)
1315
+ echo "$pidv ${st:-?}" ;;
1316
+ esac
1317
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":$(ops_json_str "${st:-PROPOSED}"),\"reject_reason\":$(ops_json_str "$reason"),\"graph_version_applied\":$(ops_json_str "$applied")}"
1318
+ }
1319
+
874
1320
  # ── status ───────────────────────────────────────────────────────────────────────────
875
1321
  # Informe local del agente: modo, proyecto (con sus hechos: scaffold/fuente de diseño/
876
1322
  # tipo/caparazón — la precondición temprana de /build:slice y /build:work la evalúan de
@@ -1044,30 +1490,22 @@ cmd_escalate() {
1044
1490
  }
1045
1491
 
1046
1492
  # ── Helpers comunes de los subcomandos que transicionan ──────────────────────────────
1047
- # _ops_dual_mirror <kind> <payload_json> — en `dual` el fichero es primario: se añade la
1048
- # identidad local al payload y se manda al espejo. Un fallo del espejo NUNCA bloquea:
1049
- # se avisa y se devuelve 0 (queda como discrepancia del comparador, spec §6.1).
1493
+ # _ops_dual_mirror <event_type> <payload_json> — en `dual` el fichero es primario: la transición
1494
+ # se manda al espejo con el `epic_code` del estado local. Un fallo del espejo NUNCA bloquea: se
1495
+ # avisa y se devuelve 0 (queda como discrepancia del comparador, spec §6.1).
1496
+ #
1497
+ # [EP-OR-17] El `slice_ref` que se inyectaba DENTRO del payload se ha eliminado: los schemas del
1498
+ # catálogo son `extra="forbid"` y ese campo era rechazo garantizado. Su información —de qué
1499
+ # épica es la transición— es justamente el `epic_code` del SOBRE, que ahora sí viaja donde el
1500
+ # hub lo lee.
1050
1501
  _ops_dual_mirror() {
1051
- local kind="$1" payload="$2" ref pfile rc
1052
- ref="$(ops_local_slice_ref)"
1502
+ local event_type="$1" payload="$2" epic pfile rc
1503
+ epic="$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)"
1053
1504
  pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OK
1054
- command -v python3 >/dev/null 2>&1 && python3 - "$pfile" "$ref" <<'PY' 2>/dev/null
1055
- import json,sys
1056
- p,ref=sys.argv[1],sys.argv[2]
1057
- try:
1058
- d=json.load(open(p))
1059
- except Exception:
1060
- d={}
1061
- try:
1062
- d["slice_ref"]=json.loads(ref)
1063
- except Exception:
1064
- d["slice_ref"]={}
1065
- json.dump(d,open(p,"w"),ensure_ascii=False)
1066
- PY
1067
- ops_mirror "$kind" "$pfile"
1505
+ ops_mirror "$event_type" "$pfile" "$epic"
1068
1506
  rc=$?
1069
1507
  rm -f "$pfile"
1070
- [ $rc -eq 0 ] || echo "⚠ transición «${kind}» no espejada (rc $rc): el comparador dual la marcará" >&2
1508
+ [ $rc -eq 0 ] || echo "⚠ transición «${event_type}» no espejada (rc $rc): el comparador dual la marcará" >&2
1071
1509
  return $OPS_RC_OK
1072
1510
  }
1073
1511
 
@@ -1411,7 +1849,9 @@ PY
1411
1849
  fi
1412
1850
  mode="$(ops_mode)"
1413
1851
  if [ "$mode" = "dual" ]; then
1414
- ops_mirror wiring_checklist_seeded "$pfile" || echo "⚠ siembra de wiring no espejada; el comparador dual la marcará" >&2
1852
+ ops_mirror wiring_checklist_seeded "$pfile" \
1853
+ "$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)" \
1854
+ || echo "⚠ siembra de wiring no espejada; el comparador dual la marcará" >&2
1415
1855
  rm -f "$pfile"
1416
1856
  return $OPS_RC_OK
1417
1857
  fi
@@ -1557,6 +1997,8 @@ case "$SUB" in
1557
1997
  propose-epic) cmd_propose_epic "$@"; exit $? ;;
1558
1998
  epic-status) cmd_epic_status "$@"; exit $? ;;
1559
1999
  epic-writeback) cmd_epic_writeback "$@"; exit $? ;;
2000
+ graph-sync) cmd_graph_sync "$@"; exit $? ;;
2001
+ graph-sync-status) cmd_graph_sync_status "$@"; exit $? ;;
1560
2002
  status) cmd_status "$@"; exit $? ;;
1561
2003
  escalate) cmd_escalate "$@"; exit $? ;;
1562
2004
  ""|-h|--help|help) usage; exit $OPS_RC_USAGE ;;