@trycore/spec-build-harness 0.14.2 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/VERSION +1 -1
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +2 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +37 -2
- package/docs/runtime/protocolo-cliente-runtime.md +56 -7
- package/hooks/build/lib/runtime-client.sh +55 -12
- package/hooks/build/lib/runtime-ops.sh +135 -27
- package/hooks/build/release-ops.sh +13 -8
- package/hooks/build/slice-ops.sh +409 -41
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/tests/test-install.sh +2 -1
- package/scripts/tests/test-runtime-client.sh +49 -3
- package/scripts/tests/test-skill-ops.sh +516 -27
package/hooks/build/slice-ops.sh
CHANGED
|
@@ -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
|
|
9
|
-
#
|
|
10
|
-
# propose-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -945,6 +1029,294 @@ PY
|
|
|
945
1029
|
return $OPS_RC_OK
|
|
946
1030
|
}
|
|
947
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
|
+
|
|
948
1320
|
# ── status ───────────────────────────────────────────────────────────────────────────
|
|
949
1321
|
# Informe local del agente: modo, proyecto (con sus hechos: scaffold/fuente de diseño/
|
|
950
1322
|
# tipo/caparazón — la precondición temprana de /build:slice y /build:work la evalúan de
|
|
@@ -1118,30 +1490,22 @@ cmd_escalate() {
|
|
|
1118
1490
|
}
|
|
1119
1491
|
|
|
1120
1492
|
# ── Helpers comunes de los subcomandos que transicionan ──────────────────────────────
|
|
1121
|
-
# _ops_dual_mirror <
|
|
1122
|
-
#
|
|
1123
|
-
#
|
|
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.
|
|
1124
1501
|
_ops_dual_mirror() {
|
|
1125
|
-
local
|
|
1126
|
-
|
|
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)"
|
|
1127
1504
|
pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OK
|
|
1128
|
-
|
|
1129
|
-
import json,sys
|
|
1130
|
-
p,ref=sys.argv[1],sys.argv[2]
|
|
1131
|
-
try:
|
|
1132
|
-
d=json.load(open(p))
|
|
1133
|
-
except Exception:
|
|
1134
|
-
d={}
|
|
1135
|
-
try:
|
|
1136
|
-
d["slice_ref"]=json.loads(ref)
|
|
1137
|
-
except Exception:
|
|
1138
|
-
d["slice_ref"]={}
|
|
1139
|
-
json.dump(d,open(p,"w"),ensure_ascii=False)
|
|
1140
|
-
PY
|
|
1141
|
-
ops_mirror "$kind" "$pfile"
|
|
1505
|
+
ops_mirror "$event_type" "$pfile" "$epic"
|
|
1142
1506
|
rc=$?
|
|
1143
1507
|
rm -f "$pfile"
|
|
1144
|
-
[ $rc -eq 0 ] || echo "⚠ transición «${
|
|
1508
|
+
[ $rc -eq 0 ] || echo "⚠ transición «${event_type}» no espejada (rc $rc): el comparador dual la marcará" >&2
|
|
1145
1509
|
return $OPS_RC_OK
|
|
1146
1510
|
}
|
|
1147
1511
|
|
|
@@ -1485,7 +1849,9 @@ PY
|
|
|
1485
1849
|
fi
|
|
1486
1850
|
mode="$(ops_mode)"
|
|
1487
1851
|
if [ "$mode" = "dual" ]; then
|
|
1488
|
-
ops_mirror wiring_checklist_seeded "$pfile"
|
|
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
|
|
1489
1855
|
rm -f "$pfile"
|
|
1490
1856
|
return $OPS_RC_OK
|
|
1491
1857
|
fi
|
|
@@ -1631,6 +1997,8 @@ case "$SUB" in
|
|
|
1631
1997
|
propose-epic) cmd_propose_epic "$@"; exit $? ;;
|
|
1632
1998
|
epic-status) cmd_epic_status "$@"; exit $? ;;
|
|
1633
1999
|
epic-writeback) cmd_epic_writeback "$@"; exit $? ;;
|
|
2000
|
+
graph-sync) cmd_graph_sync "$@"; exit $? ;;
|
|
2001
|
+
graph-sync-status) cmd_graph_sync_status "$@"; exit $? ;;
|
|
1634
2002
|
status) cmd_status "$@"; exit $? ;;
|
|
1635
2003
|
escalate) cmd_escalate "$@"; exit $? ;;
|
|
1636
2004
|
""|-h|--help|help) usage; exit $OPS_RC_USAGE ;;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trycore/spec-build-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
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": {
|