@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.
@@ -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
@@ -16,12 +17,20 @@
16
17
  set -uo pipefail
17
18
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
19
  source "$HERE/lib/runtime-ops.sh"
20
+ # [#74] `register` manda el mismo harness_version/asset_types que el arranque de sesión.
21
+ source "$HERE/lib/harness-meta.sh"
19
22
 
20
23
  usage() {
21
24
  cat >&2 <<'USAGE'
22
25
  uso: slice-ops.sh <subcomando> [opciones]
23
26
 
24
27
  mode imprime legacy|dual|runtime
28
+ register [--runtime-url U] [--force] da de alta ESTE árbol como agente propio con
29
+ un token que el ADMIN emitió para él (el token
30
+ se pide por STDIN, nunca por argumento).
31
+ Obligatorio en un worktree antes de escribir:
32
+ sin él se opera con la identidad heredada del
33
+ clon principal.
25
34
  claim reclama trabajo (solo modo runtime; el hub
26
35
  reparte por orden de cola — no hay --epic)
27
36
  next-step deriva la siguiente acción desde la caché
@@ -46,10 +55,16 @@ uso: slice-ops.sh <subcomando> [opciones]
46
55
  epic-writeback [--id P] [--file F] escribe en epicas.md las épicas ya APROBADAS,
47
56
  con el código que asignó el hub (F debe estar
48
57
  bajo docs/03-backlog/: carve-out §9.2)
58
+ graph-sync --file epics.json [--dry-run] propone al hub el re-sync del grafo desde los
59
+ docs de discovery (el humano aprueba; con
60
+ --dry-run solo enseña el bundle, sin enviar)
61
+ graph-sync-status [--id P] veredicto del re-sync (QUEUED|PROPOSED|
62
+ APPROVED|REJECTED con el motivo del ADMIN)
49
63
  status [--no-refresh] informe local del agente
50
64
  escalate «razón» | --file F [--gate G] registra un bloqueo (la decisión es humana)
51
65
 
52
66
  códigos de salida: 0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo
67
+ · 9 identidad heredada (este worktree no está dado de alta: `register`)
53
68
  USAGE
54
69
  }
55
70
 
@@ -72,6 +87,78 @@ except Exception:
72
87
  ' "$2" 2>/dev/null
73
88
  }
74
89
 
90
+ # [EP-OR-17] Los motivos del 409 del claim, como los emite el hub DE VERDAD.
91
+ #
92
+ # Este `case` esperaba `drift`/`contention`/`context_unresolvable`/`cross_project` como valor de
93
+ # `reason` en la RAÍZ de la respuesta. Ninguno de esos literales existe en el backend del hub:
94
+ # un HTTPException de FastAPI anida SIEMPRE bajo `detail`, y los cuatro casos llegan como
95
+ # • drift de contexto → detail dict con `manifest_hash` (el hash al que sincronizar)
96
+ # • contención → detail str «contención con otra escritura del contexto: reintenta…»
97
+ # • contexto irresoluble → detail str «…no hay trabajo sin contexto declarado»
98
+ # • lease ajeno/expirado → detail str «El lease está expirado, es de otro agente o no existe…»
99
+ # Todos caían en el `*)` y salían como rechazo duro: un drift —que se resuelve sincronizando y
100
+ # reintentando— dejaba al agente sin claim y sin saber por qué. La clasificación por FORMA (el
101
+ # dict con manifest_hash) manda sobre la clasificación por texto, que solo se usa donde el hub
102
+ # construye el mensaje en un único sitio. Se conserva la lectura plana por compatibilidad.
103
+
104
+ # _claim_409_kind <json> — clasifica el 409: drift|contention|context_unresolvable|
105
+ # cross_project|"" (desconocido).
106
+ _claim_409_kind() {
107
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
108
+ printf '%s' "$1" | python3 -c '
109
+ import json,sys
110
+ try:
111
+ d=json.load(sys.stdin)
112
+ except Exception:
113
+ print(""); raise SystemExit(0)
114
+ if not isinstance(d,dict):
115
+ print(""); raise SystemExit(0)
116
+ det=d.get("detail")
117
+ # Compatibilidad: una instancia que sí nombrara el motivo en un campo manda sobre el texto.
118
+ for fuente in (det if isinstance(det,dict) else {}, d):
119
+ marca=fuente.get("reason") or fuente.get("error")
120
+ if isinstance(marca,str) and marca:
121
+ print(marca); raise SystemExit(0)
122
+ # El drift se reconoce por FORMA, no por texto: el borde del hub garantiza el `manifest_hash`
123
+ # en el cuerpo precisamente para que el cliente sepa a qué contexto sincronizar.
124
+ if isinstance(det,dict) and det.get("manifest_hash"):
125
+ print("drift"); raise SystemExit(0)
126
+ texto=det if isinstance(det,str) else (det.get("message","") if isinstance(det,dict) else "")
127
+ t=str(texto).lower()
128
+ if "contenci" in t:
129
+ print("contention")
130
+ elif "no hay trabajo sin contexto" in t:
131
+ print("context_unresolvable")
132
+ elif "otro agente" in t or "otro proyecto" in t:
133
+ print("cross_project")
134
+ else:
135
+ print("")
136
+ ' 2>/dev/null
137
+ }
138
+
139
+ # _claim_409_text <json> — el motivo del hub en prosa, para enseñárselo al humano sin volcarle
140
+ # el JSON crudo encima. Cae al cuerpo entero si no hay nada mejor.
141
+ _claim_409_text() {
142
+ command -v python3 >/dev/null 2>&1 || { printf '%s' "$1"; return; }
143
+ printf '%s' "$1" | python3 -c '
144
+ import json,sys
145
+ crudo=sys.stdin.read()
146
+ try:
147
+ d=json.loads(crudo)
148
+ except Exception:
149
+ print(crudo.strip()); raise SystemExit(0)
150
+ if not isinstance(d,dict):
151
+ print(crudo.strip()); raise SystemExit(0)
152
+ det=d.get("detail")
153
+ if isinstance(det,str) and det:
154
+ print(det)
155
+ elif isinstance(det,dict):
156
+ print(det.get("message") or json.dumps(det,ensure_ascii=False))
157
+ else:
158
+ print(crudo.strip())
159
+ ' 2>/dev/null
160
+ }
161
+
75
162
  # _claim_render <json> — resumen compacto del slice reclamado (lo lee el humano y el modelo).
76
163
  _claim_render() {
77
164
  command -v python3 >/dev/null 2>&1 || { echo "$1"; return; }
@@ -127,31 +214,33 @@ _claim_once() {
127
214
  echo "NO_WORK: el runtime no tiene trabajo disponible para este agente"
128
215
  return $OPS_RC_NO_WORK ;;
129
216
  409)
130
- reason="$(_claim_field "$resp" reason)"
131
- [ -n "$reason" ] || reason="$(_claim_field "$resp" error)"
217
+ # [EP-OR-17] El grafo rancio se comprueba ANTES del `case`, con el helper del cliente:
218
+ # conoce la forma REAL del hub (`detail.error = graph_version_stale`) además de la plana
219
+ # que este sitio leía a mano y que el hub no emite en ninguna parte.
220
+ local server_gv
221
+ if server_gv="$(runtime_stale_graph_version "$resp")"; then
222
+ # [#63] No es un error: es la señal de que operamos contra una foto vieja del
223
+ # backlog. Se anota el desfase (lo muestra `status`) y se pide UN reintento con la
224
+ # proyección refrescada. Mismo patrón que el 409 del lease en hb_renew.
225
+ runtime_graph_note_stale "$gv" "$server_gv"
226
+ echo "⚠ 409 (graph_version_stale): el grafo local (v${gv:-?}) va por detrás del hub (v${server_gv:-?})" >&2
227
+ return 21
228
+ fi
229
+ reason="$(_claim_409_kind "$resp")"
132
230
  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
231
  drift|contention)
143
- echo "⚠ 409 ($reason): reintentable" >&2
232
+ echo "⚠ 409 ($reason): reintentable — $(_claim_409_text "$resp")" >&2
144
233
  return 20 ;;
145
234
  context_unresolvable)
146
- echo "⛔ 409 contexto irresoluble — NO reintentar: $resp" >&2
235
+ echo "⛔ 409 contexto irresoluble — NO reintentar: $(_claim_409_text "$resp")" >&2
147
236
  echo " Resuélvelo en el hub (propuesta → publicación) y vuelve a reclamar." >&2
148
237
  return $OPS_RC_REJECTED ;;
149
238
  cross_project)
150
- echo "⛔ 409 cruce de proyecto (queda auditado en el servidor): $resp" >&2
239
+ echo "⛔ 409 cruce de proyecto (queda auditado en el servidor): $(_claim_409_text "$resp")" >&2
151
240
  echo " Este clon está registrado contra otro proyecto: revisa runtime.credentials." >&2
152
241
  return $OPS_RC_REJECTED ;;
153
242
  *)
154
- echo "⛔ 409 sin razón reconocida en el cuerpo: $resp" >&2
243
+ echo "⛔ 409 del hub: $(_claim_409_text "$resp")" >&2
155
244
  return $OPS_RC_REJECTED ;;
156
245
  esac ;;
157
246
  401|403)
@@ -322,11 +411,15 @@ cmd_fact() {
322
411
  # escribible, python3 ausente u otro fallo local) — el caller decide qué avisar y nunca debe
323
412
  # reportar éxito en ese caso (fail-open sobre el trabajo local, nunca sobre el reporte).
324
413
  _ops_emit_event() {
325
- local tipo="$1" payload="$2" sid="${3:-}" pfile mode
414
+ local tipo="$1" payload="$2" sid="${3:-}" pfile mode epic
326
415
  mode="$(ops_mode)"
327
416
  pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
328
417
  if [ "$mode" = "dual" ]; then
329
- ops_mirror "$tipo" "$pfile" || echo "⚠ «${tipo}» no espejado; el comparador dual lo marcará" >&2
418
+ # [EP-OR-17] El `epic_code` va en el SOBRE del espejo, que es donde el hub lo lee. Muchos de
419
+ # los tipos que pasan por aquí (hechos de proyecto, archivado, escalada) no son espejables:
420
+ # `ops_mirror` los corta en local con rc 8 antes de gastar una llamada.
421
+ epic="$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)"
422
+ ops_mirror "$tipo" "$pfile" "$epic" || echo "⚠ «${tipo}» no espejado; el comparador dual lo marcará" >&2
330
423
  rm -f "$pfile"
331
424
  return $OPS_RC_OK
332
425
  fi
@@ -945,6 +1038,294 @@ PY
945
1038
  return $OPS_RC_OK
946
1039
  }
947
1040
 
1041
+ # ── graph-sync ───────────────────────────────────────────────────────────────────────
1042
+ # [EP-OR-17] Hasta ahora el grafo del hub dejaba de evolucionar tras el import inicial de
1043
+ # ADMIN: las épicas que discovery añadía después no tenían por dónde entrar, y TODOS los
1044
+ # eventos de un slice cuya épica no está en el grafo rebotan con «la épica no existe en el
1045
+ # grafo del proyecto». Este carril lo cierra sin regalarle al agente la potestad de mutar el
1046
+ # grafo: el arnés PROPONE el bundle, el hub calcula el delta y un humano aprueba en la consola.
1047
+
1048
+ # _graph_sync_bundle <fichero-epics> [from_docs] — imprime el bundle proyectado al contrato,
1049
+ # o "" con rc 2 (no cumple el contrato) / rc 5 (falta infraestructura local).
1050
+ _graph_sync_bundle() {
1051
+ local f="$1" docs="${2:-}" gb
1052
+ gb="$HERE/../../scripts/lib/graph-bundle.py"
1053
+ [ -f "$gb" ] || gb="$(config_root)/.claude/scripts/lib/graph-bundle.py"
1054
+ if [ ! -f "$gb" ]; then
1055
+ echo "⛔ no se encuentra graph-bundle.py: reinstala con \`trycore-build update\`" >&2
1056
+ return $OPS_RC_OFFLINE
1057
+ fi
1058
+ # [Importante] Sin python3 no es "bundle inválido" (rc 2 de uso): es una degradación de
1059
+ # infraestructura. Se anuncia con esas palabras para que no se confunda con un backlog mal
1060
+ # escrito, que es lo que el agente iría a arreglar.
1061
+ command -v python3 >/dev/null 2>&1 || {
1062
+ echo "⛔ sin python3: no se puede construir el bundle en local" >&2
1063
+ return $OPS_RC_OFFLINE
1064
+ }
1065
+ if [ -n "$docs" ]; then
1066
+ python3 "$gb" --for-sync --from-docs "$docs" < "$f"
1067
+ else
1068
+ python3 "$gb" --for-sync < "$f"
1069
+ fi
1070
+ }
1071
+
1072
+ # _graph_sync_out <respuesta> <clave> — escalar de la respuesta del hub ("" si falta). Acepta
1073
+ # tanto el cuerpo desnudo como el sobre `{"response": …}` con el que el carril directo guarda
1074
+ # los acks, que es de donde lo lee este comando.
1075
+ _graph_sync_out() {
1076
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
1077
+ printf '%s' "$1" | python3 -c '
1078
+ import json,sys
1079
+ try:
1080
+ d=json.load(sys.stdin)
1081
+ r=(d.get("response") if isinstance(d.get("response"),dict) else d) or {}
1082
+ v=r.get(sys.argv[1])
1083
+ print("" if v is None else (v if isinstance(v,str) else json.dumps(v,ensure_ascii=False)))
1084
+ except Exception:
1085
+ print("")
1086
+ ' "$2" 2>/dev/null
1087
+ }
1088
+
1089
+ # _graph_sync_delta_render <delta-json> — resumen legible del delta que devuelve el hub. Es lo
1090
+ # que el humano va a aprobar: enseñarlo aquí evita que el agente proponga a ciegas.
1091
+ _graph_sync_delta_render() {
1092
+ command -v python3 >/dev/null 2>&1 || return 0
1093
+ printf '%s' "$1" | python3 -c '
1094
+ import json,sys
1095
+ try:
1096
+ d=json.load(sys.stdin)
1097
+ except Exception:
1098
+ raise SystemExit(0)
1099
+ if not isinstance(d,dict):
1100
+ raise SystemExit(0)
1101
+ print("delta propuesto: %d épicas nuevas · %d épicas con cambios · %d historias nuevas · %d historias con cambios"
1102
+ % (len(d.get("epics_added") or []), len(d.get("epics_changed") or []),
1103
+ len(d.get("stories_added") or []), len(d.get("stories_changed") or [])))
1104
+ for e in (d.get("epics_added") or [])[:10]:
1105
+ print(" + %s %s" % (e.get("code",""), e.get("title","")))
1106
+ for e in (d.get("epics_changed") or [])[:10]:
1107
+ print(" ~ %s (%s)" % (e.get("code",""), ", ".join(sorted((e.get("fields") or {}).keys()))))
1108
+ ' 2>/dev/null
1109
+ }
1110
+
1111
+ cmd_graph_sync() {
1112
+ local file="" docs="" dry=0 mode pid bundle rc cid pfile n_epicas
1113
+ while [ $# -gt 0 ]; do
1114
+ case "$1" in
1115
+ --file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
1116
+ --from-docs) docs="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
1117
+ --dry-run) dry=1; shift ;;
1118
+ *) shift ;;
1119
+ esac
1120
+ done
1121
+ if [ -z "$file" ]; then
1122
+ echo "uso: slice-ops.sh graph-sync --file epics.json [--from-docs docs/03-backlog/epicas.md] [--dry-run]" >&2
1123
+ echo " epics.json: {\"epics\":[{code,title,layer,files_scope[],depends_on[],stories[]}]}" >&2
1124
+ echo " lo escribes leyendo docs/03-backlog/epicas.md y las HU-*.md (igual que en /build:onboard)." >&2
1125
+ return $OPS_RC_USAGE
1126
+ fi
1127
+ [ -f "$file" ] || { echo "⛔ no existe el fichero de épicas: $file" >&2; return $OPS_RC_USAGE; }
1128
+ [ -z "$docs" ] || [ -f "$docs" ] || { echo "⛔ no existe el documento de épicas: $docs" >&2; return $OPS_RC_USAGE; }
1129
+
1130
+ # El bundle se construye ANTES de mirar el modo: `--dry-run` tiene que servir también en
1131
+ # legacy y dual, que es donde más falta hace revisar el grafo antes de registrarse.
1132
+ bundle="$(_graph_sync_bundle "$file" "$docs")"
1133
+ rc=$?
1134
+ if [ $rc -ne 0 ] || [ -z "$bundle" ]; then
1135
+ [ $rc -eq $OPS_RC_OFFLINE ] && return $OPS_RC_OFFLINE
1136
+ echo "⛔ el bundle no se envía: corrige los docs de discovery y vuelve a intentarlo." >&2
1137
+ return $OPS_RC_USAGE
1138
+ fi
1139
+ if [ "$dry" = 1 ]; then
1140
+ printf '%s\n' "$bundle"
1141
+ echo "--dry-run: nada se ha enviado. Quita la opción para proponer el re-sync al hub." >&2
1142
+ return $OPS_RC_OK
1143
+ fi
1144
+
1145
+ mode="$(ops_mode)"
1146
+ if [ "$mode" != "runtime" ]; then
1147
+ echo "${mode}: el re-sync del grafo solo opera en modo \`runtime\`."
1148
+ echo "Con el fichero local como fuente de verdad, el grafo vive en los documentos y entra"
1149
+ echo "al hub por el import de admin (\`/build:onboard\`). Usa --dry-run para revisarlo."
1150
+ return $OPS_RC_LEGACY
1151
+ fi
1152
+ pid="$(runtime_field project_id)"
1153
+ [ -n "$pid" ] || { echo "⛔ sin project_id en runtime.credentials: corre \`trycore-build init\`" >&2; return $OPS_RC_REJECTED; }
1154
+
1155
+ cid="$(ops_graph_sync_cid)"
1156
+ # El cid es determinista, luego REUTILIZABLE: si el ledger se pierde o se limpia, el
1157
+ # contador vuelve atrás y el rastro local de un envío anterior con ese mismo cid (su ack o
1158
+ # su apartado) seguiría en disco. Leerlo haría que este comando reportara el resultado de
1159
+ # una propuesta que NO acaba de enviar. Se retira antes de encolar: el hub sigue
1160
+ # deduplicando por cid igualmente, así que borrar el rastro local no duplica nada.
1161
+ rm -f "$(runtime_outbox_dir)/acks/${cid}.json" 2>/dev/null
1162
+ rm -f "$(runtime_outbox_dir)/rejected/"*"${cid}.json" 2>/dev/null
1163
+ pfile="$(ops_tmpjson "{\"bundle\":$bundle}")" || return $OPS_RC_OFFLINE
1164
+ # El `1` pide el estampado de `graph_version` AL DESPACHAR: un re-sync que espera en la cola
1165
+ # offline debe salir con la versión vigente en ese momento, no con la de ayer. El `$cid` fija
1166
+ # la identidad: el reintento tras un 409 la reusa y el hub deduplica.
1167
+ cid="$(runtime_enqueue_direct graph_sync_proposed POST "$(ops_path_graph_sync_proposals "$pid")" "$pfile" 1 "$cid")"
1168
+ rm -f "$pfile"
1169
+ if [ -z "$cid" ]; then
1170
+ echo "⛔ no se pudo encolar el re-sync (outbox no escribible): reintenta." >&2
1171
+ return $OPS_RC_OFFLINE
1172
+ fi
1173
+ n_epicas="$(printf '%s' "$bundle" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["epics"]))' 2>/dev/null || echo 0)"
1174
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"QUEUED\",\"proposal_id\":null,\"epics\":$n_epicas}"
1175
+
1176
+ _graph_sync_despachar "$cid"
1177
+ }
1178
+
1179
+ # _graph_sync_despachar <cid> — despacha, interpreta el resultado y, ante un grafo rancio,
1180
+ # refresca la proyección y reintenta UNA vez.
1181
+ #
1182
+ # El reintento vive AQUÍ y no en el despachador porque `runtime-client.sh` es agnóstico al
1183
+ # dominio: no sabe qué es un bundle ni cuándo vale la pena volver a intentarlo. Y re-estampar
1184
+ # con la versión nueva ES «recalcular el bundle contra la versión que devuelve el hub»: el
1185
+ # bundle no depende de la versión del grafo, solo el sello que lo acompaña.
1186
+ _graph_sync_despachar() {
1187
+ local cid="$1" intento=1 ack reason id_prop delta
1188
+ while :; do
1189
+ runtime_dispatch_outbox >/dev/null 2>&1
1190
+ ack="$(ops_epic_ack_read "$cid")"
1191
+ if [ -n "$ack" ]; then
1192
+ if [ "$(_graph_sync_out "$ack" empty_delta)" = "true" ]; then
1193
+ # HU-OR-72 E2: no es un error ni una propuesta — el bundle coincide con el grafo
1194
+ # vigente. Se cierra la entrada del ledger para que `graph-sync-status` no vaya a
1195
+ # preguntar por una propuesta que el hub nunca creó.
1196
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"EMPTY_DELTA\"}"
1197
+ echo "el grafo del hub ya está al día: no hay cambios que proponer."
1198
+ return $OPS_RC_OK
1199
+ fi
1200
+ id_prop="$(_graph_sync_out "$ack" id)"
1201
+ delta="$(_graph_sync_out "$ack" delta)"
1202
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$id_prop")}"
1203
+ echo "re-sync propuesto: ${id_prop:-sin identificador en la respuesta}"
1204
+ [ -n "$delta" ] && _graph_sync_delta_render "$delta"
1205
+ echo "Queda en PROPOSED: NADA se ha aplicado al grafo hasta que un humano lo apruebe en"
1206
+ echo "la consola del hub. Sigue el veredicto con \`slice-ops.sh graph-sync-status\`."
1207
+ return $OPS_RC_OK
1208
+ fi
1209
+ reason="$(ops_epic_rejected_reason "$cid")"
1210
+ if [ -n "$reason" ]; then
1211
+ ops_graph_sync_ledger_upsert "{\"client_event_id\":$(ops_json_str "$cid"),\"status\":\"FAILED\",\"reason\":$(ops_json_str "$reason")}"
1212
+ case "$reason" in
1213
+ *"HTTP 404"*|*404*)
1214
+ # Anti-oráculo (HU-OR-72 E4): el hub responde lo MISMO para «no existe» y «no es
1215
+ # tuyo». Afirmar una de las dos causas mandaría a buscar el problema equivocado.
1216
+ echo "⛔ el hub respondió 404. Son dos causas indistinguibles POR DISEÑO: o el proyecto" >&2
1217
+ echo " no existe, o no es el tuyo (el hub no confirma cuál para no filtrar proyectos" >&2
1218
+ echo " ajenos). Verifica el project_id de runtime.credentials y que el hub tenga" >&2
1219
+ echo " la superficie de re-sync (EP-OR-17)." >&2 ;;
1220
+ *) echo "⛔ el hub rechazó el re-sync: $reason" >&2 ;;
1221
+ esac
1222
+ return $OPS_RC_REJECTED
1223
+ fi
1224
+ # Sigue en la cola. Si fue por grafo rancio, el despachador dejó el marcador de desfase: se
1225
+ # refresca la proyección y se reintenta UNA vez con la versión nueva.
1226
+ if [ "$intento" = 1 ] && [ -n "$(runtime_graph_status_read)" ] && [ "$(runtime_graph_status_read)" != "{}" ]; then
1227
+ intento=2
1228
+ echo "⚠ el hub dice que nuestro grafo va rancio: se refresca el contexto y se reintenta una vez." >&2
1229
+ agent_context_fetch_and_cache >/dev/null 2>&1 || \
1230
+ echo "⚠ no se pudo refrescar /agent/context: el reintento sale con la versión que haya" >&2
1231
+ # El despacho lleva mutex y estado de backoff: sin limpiarlo, el segundo intento cedería
1232
+ # el turno y el reintento no ocurriría en este proceso.
1233
+ rm -f "$(runtime_outbox_dir)/.dispatch-state.json" 2>/dev/null
1234
+ continue
1235
+ fi
1236
+ echo "re-sync encolado: $cid"
1237
+ echo "PENDIENTE: se despachará solo (el daemon de heartbeat lo lleva). Sin conexión aún, o el"
1238
+ echo "daemon estaba drenando la cola en este instante. \`slice-ops.sh graph-sync-status\` lo sigue."
1239
+ return $OPS_RC_OFFLINE
1240
+ done
1241
+ }
1242
+
1243
+ # ── graph-sync-status ────────────────────────────────────────────────────────────────
1244
+ # [EP-OR-17] El motivo del rechazo lo escribe el ADMIN precisamente para que lo lea la terminal
1245
+ # que propuso: sin este subcomando el veredicto humano se queda en la consola del hub y el
1246
+ # agente vuelve a proponer exactamente lo mismo. Informativo: rc 0 siempre en runtime, pase lo
1247
+ # que pase — es una consulta, y fallar aquí no arregla nada.
1248
+ cmd_graph_sync_status() {
1249
+ local only="" mode pid entries n i cid pidv st resp status reason ack
1250
+ while [ $# -gt 0 ]; do
1251
+ case "$1" in
1252
+ --id) only="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
1253
+ *) shift ;;
1254
+ esac
1255
+ done
1256
+ mode="$(ops_mode)"
1257
+ if [ "$mode" != "runtime" ]; then
1258
+ echo "${mode}: no hay hub al que preguntar por el re-sync del grafo."
1259
+ return $OPS_RC_LEGACY
1260
+ fi
1261
+ pid="$(runtime_field project_id)"
1262
+ entries="$(ops_graph_sync_ledger_read)"
1263
+ n="$(printf '%s' "$entries" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("proposals") or []))' 2>/dev/null || echo 0)"
1264
+ case "$n" in ''|*[!0-9]*) n=0 ;; esac
1265
+ if [ "$n" = 0 ]; then
1266
+ echo "no hay ninguna propuesta de re-sync registrada en esta terminal."
1267
+ echo "Propón una con \`slice-ops.sh graph-sync --file epics.json\`."
1268
+ return $OPS_RC_OK
1269
+ fi
1270
+ i=0
1271
+ while [ "$i" -lt "$n" ]; do
1272
+ 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)"
1273
+ 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)"
1274
+ 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)"
1275
+ i=$((i + 1))
1276
+ [ -z "$only" ] || [ "$only" = "$pidv" ] || [ "$only" = "$cid" ] || continue
1277
+ if [ -z "$pidv" ]; then
1278
+ # Todavía sin identificador del hub: el estado lo dicta el carril directo local, no la red.
1279
+ ack="$(ops_epic_ack_read "$cid")"
1280
+ reason="$(ops_epic_rejected_reason "$cid")"
1281
+ if [ "$st" = "EMPTY_DELTA" ]; then
1282
+ echo "$cid EMPTY_DELTA (el grafo ya estaba al día: el hub no creó ninguna propuesta)"
1283
+ elif [ -n "$reason" ]; then
1284
+ echo "$cid FAILED $reason"
1285
+ elif [ -n "$ack" ]; then
1286
+ echo "$cid PROPOSED (ack recibido, sin identificador legible en la respuesta)"
1287
+ else
1288
+ echo "$cid ${st:-QUEUED} (pendiente de despacho: el daemon de heartbeat la lleva)"
1289
+ fi
1290
+ continue
1291
+ fi
1292
+ resp="$(runtime_get "$(ops_path_graph_sync_proposal "$pid" "$pidv")")"
1293
+ status="$(runtime_http_status)"
1294
+ case "$status" in
1295
+ 200) _graph_sync_status_render "$cid" "$pidv" "$resp" ;;
1296
+ 404)
1297
+ # HU-OR-73 E5: «no existe» y «es de otro proyecto» son el MISMO cuerpo, bit a bit.
1298
+ echo "$pidv DESCONOCIDA para el hub (o es de otro proyecto: son indistinguibles)" ;;
1299
+ *)
1300
+ echo "$pidv ${st:-?} (el hub no respondió, HTTP $status: se muestra el último estado conocido)" ;;
1301
+ esac
1302
+ done
1303
+ return $OPS_RC_OK
1304
+ }
1305
+
1306
+ # _graph_sync_status_render <cid> <proposal_id> <respuesta> — pinta el veredicto y lo persiste
1307
+ # en el ledger. La persistencia va aquí y no en el llamador porque el estado del hub SOLO se
1308
+ # conoce al leer la respuesta: guardarlo fuera obligaría a re-parsearla.
1309
+ _graph_sync_status_render() {
1310
+ local cid="$1" pidv="$2" resp="$3" st reason applied
1311
+ st="$(_graph_sync_out "$resp" status)"
1312
+ reason="$(_graph_sync_out "$resp" reject_reason)"
1313
+ applied="$(_graph_sync_out "$resp" graph_version_applied)"
1314
+ case "$st" in
1315
+ APPROVED)
1316
+ echo "$pidv APPROVED (grafo del hub en v${applied:-?})"
1317
+ echo " nada más que hacer: las historias nuevas entran solas al reparto del claim." ;;
1318
+ REJECTED)
1319
+ echo "$pidv REJECTED ${reason:-sin motivo registrado}"
1320
+ echo " corrige los docs de discovery y vuelve a proponer con \`graph-sync\`." ;;
1321
+ PROPOSED)
1322
+ echo "$pidv PROPOSED (esperando aprobación humana en la consola del hub)" ;;
1323
+ *)
1324
+ echo "$pidv ${st:-?}" ;;
1325
+ esac
1326
+ 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")}"
1327
+ }
1328
+
948
1329
  # ── status ───────────────────────────────────────────────────────────────────────────
949
1330
  # Informe local del agente: modo, proyecto (con sus hechos: scaffold/fuente de diseño/
950
1331
  # tipo/caparazón — la precondición temprana de /build:slice y /build:work la evalúan de
@@ -959,6 +1340,16 @@ cmd_status() {
959
1340
  esac
960
1341
  done
961
1342
  mode="$(ops_mode)"
1343
+ # [#74] `status` conserva el fallback a propósito — es la herramienta de diagnóstico de un
1344
+ # worktree que todavía no se dio de alta, y negarle la lectura lo dejaría a ciegas. Pero se
1345
+ # dice en la primera línea de quién es el estado que está mirando: sin el banner, un informe
1346
+ # heredado es indistinguible de uno propio, y es exactamente así como se llega a creer que
1347
+ # se tiene un lease que en realidad es de otro agente.
1348
+ if config_is_inherited_root; then
1349
+ echo "identidad: ⚠ HEREDADA del clon principal ($(config_main_clone_root)) — solo lectura"
1350
+ echo " lo de abajo es el estado de ESE agente, no del tuyo. Antes de reclamar"
1351
+ echo " o reportar: \`slice-ops.sh register\` con un token propio del ADMIN."
1352
+ fi
962
1353
  echo "modo: $mode"
963
1354
  if [ "$mode" = "legacy" ]; then
964
1355
  echo "estado: el fichero .claude/state/build-state.json es la fuente de verdad (v0.8.5)"
@@ -1118,30 +1509,22 @@ cmd_escalate() {
1118
1509
  }
1119
1510
 
1120
1511
  # ── Helpers comunes de los subcomandos que transicionan ──────────────────────────────
1121
- # _ops_dual_mirror <kind> <payload_json> — en `dual` el fichero es primario: se añade la
1122
- # identidad local al payload y se manda al espejo. Un fallo del espejo NUNCA bloquea:
1123
- # se avisa y se devuelve 0 (queda como discrepancia del comparador, spec §6.1).
1512
+ # _ops_dual_mirror <event_type> <payload_json> — en `dual` el fichero es primario: la transición
1513
+ # se manda al espejo con el `epic_code` del estado local. Un fallo del espejo NUNCA bloquea: se
1514
+ # avisa y se devuelve 0 (queda como discrepancia del comparador, spec §6.1).
1515
+ #
1516
+ # [EP-OR-17] El `slice_ref` que se inyectaba DENTRO del payload se ha eliminado: los schemas del
1517
+ # catálogo son `extra="forbid"` y ese campo era rechazo garantizado. Su información —de qué
1518
+ # épica es la transición— es justamente el `epic_code` del SOBRE, que ahora sí viaja donde el
1519
+ # hub lo lee.
1124
1520
  _ops_dual_mirror() {
1125
- local kind="$1" payload="$2" ref pfile rc
1126
- ref="$(ops_local_slice_ref)"
1521
+ local event_type="$1" payload="$2" epic pfile rc
1522
+ epic="$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)"
1127
1523
  pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OK
1128
- command -v python3 >/dev/null 2>&1 && python3 - "$pfile" "$ref" <<'PY' 2>/dev/null
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"
1524
+ ops_mirror "$event_type" "$pfile" "$epic"
1142
1525
  rc=$?
1143
1526
  rm -f "$pfile"
1144
- [ $rc -eq 0 ] || echo "⚠ transición «${kind}» no espejada (rc $rc): el comparador dual la marcará" >&2
1527
+ [ $rc -eq 0 ] || echo "⚠ transición «${event_type}» no espejada (rc $rc): el comparador dual la marcará" >&2
1145
1528
  return $OPS_RC_OK
1146
1529
  }
1147
1530
 
@@ -1485,7 +1868,9 @@ PY
1485
1868
  fi
1486
1869
  mode="$(ops_mode)"
1487
1870
  if [ "$mode" = "dual" ]; then
1488
- ops_mirror wiring_checklist_seeded "$pfile" || echo "⚠ siembra de wiring no espejada; el comparador dual la marcará" >&2
1871
+ ops_mirror wiring_checklist_seeded "$pfile" \
1872
+ "$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)" \
1873
+ || echo "⚠ siembra de wiring no espejada; el comparador dual la marcará" >&2
1489
1874
  rm -f "$pfile"
1490
1875
  return $OPS_RC_OK
1491
1876
  fi
@@ -1613,10 +1998,197 @@ cmd_checkpoint() {
1613
1998
  _ops_post_or_queue "$OPS_PATH_CHECKPOINTS" "$payload" checkpoint_recorded "$sid" "el checkpoint" "$queue_payload"
1614
1999
  }
1615
2000
 
2001
+ # ── register ─────────────────────────────────────────────────────────────────────────
2002
+ # [#74] Alta EXPLÍCITA de este árbol de trabajo como agente propio.
2003
+ #
2004
+ # El caso que lo motiva: dos agentes construyendo dos épicas en la misma máquina, cada uno en
2005
+ # su worktree. El hub reparte las dos sin objeción — el bloqueo estaba aquí: un worktree sin
2006
+ # `.claude/state/runtime.credentials` propio hereda el del clon principal por el fallback de
2007
+ # `config_root()` y trabaja con SU identidad sin decirlo.
2008
+ #
2009
+ # Tres decisiones de diseño que no son gratuitas:
2010
+ # 1. El token entra por STDIN (prompt si hay TTY), jamás por argumento: un `--token` queda
2011
+ # en el historial del shell y en `ps`. Por eso `register` es un comando de terminal
2012
+ # humano y NO se invoca desde dentro de un claim — los hooks y las tool calls no tienen
2013
+ # TTY y un `read` ahí colgaría la llamada.
2014
+ # 2. Se siembra PRIMERO el fichero local, para que `config_root()` ancle en este árbol antes
2015
+ # de cualquier merge. Al revés, `runtime_register` habría hecho upsert del token nuevo
2016
+ # sobre el fichero del clon principal, pisándole la identidad al agente que trabaja ahí.
2017
+ # 3. Se valida que el `agent_key` obtenido DIFIERA del que tiene el principal. El hub no crea
2018
+ # identidades en `/agents/register`: el `agent_key` es el `agent_id` que el ADMIN grabó en
2019
+ # el token al emitirlo. Mismo token ⇒ mismo agente ⇒ el claim devolvería el trabajo del
2020
+ # otro worktree. Un token por agente, siempre.
2021
+ #
2022
+ # Ante cualquier fallo se revierte: o el árbol queda con identidad propia y verificada, o
2023
+ # queda exactamente como estaba. Un fichero de credenciales a medias es peor que ninguno.
2024
+
2025
+ # _reg_rollback <fichero> <backup> — deja el fichero local como estaba antes de `register`.
2026
+ _reg_rollback() {
2027
+ local file="$1" backup="$2"
2028
+ if [ -n "$backup" ] && [ -f "$backup" ]; then
2029
+ cp "$backup" "$file" 2>/dev/null
2030
+ rm -f "$backup"
2031
+ else
2032
+ rm -f "$file"
2033
+ fi
2034
+ }
2035
+
2036
+ cmd_register() {
2037
+ local url="" force=0 wt main localfile mainfile mainkey newkey token backup="" hv at rc
2038
+ while [ $# -gt 0 ]; do
2039
+ case "$1" in
2040
+ --runtime-url) url="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
2041
+ --force) force=1; shift ;;
2042
+ --token|--project-token|--runtime-token)
2043
+ echo "⛔ el token NO se pasa por argumento: quedaría en el historial del shell y en \`ps\`." >&2
2044
+ echo " \`register\` lo pide por stdin. Córrelo en un terminal humano." >&2
2045
+ return $OPS_RC_USAGE ;;
2046
+ *) shift ;;
2047
+ esac
2048
+ done
2049
+ command -v python3 >/dev/null 2>&1 || {
2050
+ echo "⛔ register necesita python3 para escribir las credenciales." >&2; return $OPS_RC_OFFLINE; }
2051
+
2052
+ wt="$(config_candidate_root)"
2053
+ main="$(config_main_clone_root)"
2054
+ localfile="$wt/.claude/state/runtime.credentials"
2055
+ mainfile=""
2056
+ if [ -n "$main" ] && [ "$main" != "$wt" ]; then mainfile="$main/.claude/state/runtime.credentials"; fi
2057
+
2058
+ if [ -f "$localfile" ] && [ "$force" != 1 ]; then
2059
+ echo "este árbol YA tiene identidad propia — no se toca nada:"
2060
+ echo " raíz: $wt"
2061
+ echo " agente: $(runtime_credentials_field_at "$localfile" agent_key)"
2062
+ echo " proyecto: $(runtime_credentials_field_at "$localfile" project_id)"
2063
+ echo "(para re-registrarlo con OTRO token: \`register --force\`)"
2064
+ return $OPS_RC_OK
2065
+ fi
2066
+
2067
+ # La URL no es secreta y es la MISMA del proyecto: se hereda del principal si no se da.
2068
+ if [ -z "$url" ]; then
2069
+ url="$(runtime_credentials_field_at "$localfile" runtime_url)"
2070
+ [ -n "$url" ] || url="$(runtime_credentials_field_at "$mainfile" runtime_url)"
2071
+ fi
2072
+ case "$url" in
2073
+ http://*|https://*) : ;;
2074
+ *)
2075
+ echo "⛔ no hay runtime_url que usar (ni --runtime-url, ni credenciales del clon principal)." >&2
2076
+ echo " Pásala explícita: \`register --runtime-url https://…\`" >&2
2077
+ return $OPS_RC_USAGE ;;
2078
+ esac
2079
+
2080
+ mainkey="$(runtime_credentials_field_at "$mainfile" agent_key)"
2081
+
2082
+ if [ -t 0 ]; then
2083
+ printf 'Token de agente para este árbol (no se muestra en pantalla): ' >&2
2084
+ IFS= read -rs token
2085
+ printf '\n' >&2
2086
+ else
2087
+ IFS= read -r token || token=""
2088
+ fi
2089
+ token="$(printf '%s' "$token" | tr -d '[:space:]')"
2090
+ [ -n "$token" ] || { echo "⛔ token vacío: no se registró nada." >&2; return $OPS_RC_USAGE; }
2091
+
2092
+ # Compuerta barata y decisiva, ANTES de la red: si el token es el del clon principal, el hub
2093
+ # devolverá su misma identidad — el `agent_key` va grabado en el token, no se crea al
2094
+ # registrar. Preguntarlo por HTTP solo añadiría una llamada al mismo veredicto.
2095
+ if [ -n "$mainfile" ] && [ "$token" = "$(runtime_credentials_field_at "$mainfile" project_token)" ]; then
2096
+ token=""
2097
+ echo "⛔ ese token es la identidad del clon principal: registrarlo aquí daría el MISMO" >&2
2098
+ echo " agent_key y el claim te devolvería el trabajo que ya tiene reclamado el otro árbol." >&2
2099
+ echo " Pide al ADMIN un token con \`agent_id\` distinto (uno por agente)." >&2
2100
+ echo " El fichero del clon principal NO se ha tocado." >&2
2101
+ return $OPS_RC_REJECTED
2102
+ fi
2103
+
2104
+ if [ -f "$localfile" ]; then
2105
+ backup="$(mktemp)" && cp "$localfile" "$backup" 2>/dev/null || backup=""
2106
+ fi
2107
+
2108
+ if ! TRYCORE_REGISTER_TOKEN="$token" runtime_credentials_seed_at "$localfile" "$url"; then
2109
+ token=""
2110
+ _reg_rollback "$localfile" "$backup"
2111
+ echo "⛔ no se pudo escribir $localfile — revisa permisos." >&2
2112
+ return $OPS_RC_OFFLINE
2113
+ fi
2114
+ token="" # el secreto ya está en el fichero 0600: fuera de las variables del shell
2115
+
2116
+ hv="$(harness_version)"
2117
+ at="$(harness_asset_types)"
2118
+ runtime_register "$hv" "$at"
2119
+ rc=$?
2120
+ if [ "$rc" -ne 0 ]; then
2121
+ _reg_rollback "$localfile" "$backup"
2122
+ if [ "$rc" -eq 2 ]; then
2123
+ echo "⛔ el hub rechaza esta versión del arnés ($hv): incompatible_version." >&2
2124
+ echo " Actualiza el paquete y repite \`register\`." >&2
2125
+ return $OPS_RC_REJECTED
2126
+ fi
2127
+ case "$(runtime_http_status)" in
2128
+ 401|403)
2129
+ echo "⛔ el hub no acepta ese token (HTTP $(runtime_http_status)): pide al ADMIN uno válido" >&2
2130
+ echo " para este proyecto. Nada quedó escrito." >&2
2131
+ return $OPS_RC_REJECTED ;;
2132
+ *)
2133
+ echo "OFFLINE: no se pudo registrar contra $url (HTTP $(runtime_http_status))." >&2
2134
+ echo " Nada quedó escrito: repite \`register\` cuando haya red." >&2
2135
+ return $OPS_RC_OFFLINE ;;
2136
+ esac
2137
+ fi
2138
+
2139
+ newkey="$(runtime_credentials_field_at "$localfile" agent_key)"
2140
+ if [ -z "$newkey" ]; then
2141
+ _reg_rollback "$localfile" "$backup"
2142
+ echo "⛔ el hub respondió 200 pero sin \`agent_key\`: sin identidad verificable no se da" >&2
2143
+ echo " de alta el árbol. Nada quedó escrito." >&2
2144
+ return $OPS_RC_REJECTED
2145
+ fi
2146
+ if [ -n "$mainkey" ] && [ "$newkey" = "$mainkey" ]; then
2147
+ _reg_rollback "$localfile" "$backup"
2148
+ echo "⛔ ese token resuelve al MISMO agent_key que el clon principal ($mainkey): no es una" >&2
2149
+ echo " identidad nueva. El \`agent_key\` lo graba el ADMIN en el token al emitirlo —" >&2
2150
+ echo " pide uno con \`agent_id\` distinto. El fichero del clon principal NO se ha tocado." >&2
2151
+ return $OPS_RC_REJECTED
2152
+ fi
2153
+ rm -f "$backup"
2154
+
2155
+ # A partir de aquí `config_root()` ancla en este árbol, y con él TODA la resolución de
2156
+ # config: si `build-config.json` no viajó al worktree (no está versionado en el consumidor),
2157
+ # `runtime.mode` caería al default `legacy` y el árbol recién dado de alta dejaría de hablar
2158
+ # con el hub — el registro habría «funcionado» apagando justo lo que venía a encender. Se
2159
+ # copia del principal solo si aquí no hay ninguno: nunca se pisa una config local.
2160
+ if [ -n "$main" ] && [ ! -f "$wt/.claude/config/build-config.json" ] \
2161
+ && [ -f "$main/.claude/config/build-config.json" ]; then
2162
+ mkdir -p "$wt/.claude/config"
2163
+ cp "$main/.claude/config/build-config.json" "$wt/.claude/config/build-config.json" 2>/dev/null \
2164
+ && echo " (build-config.json copiado del clon principal: modo $(ops_mode))"
2165
+ fi
2166
+
2167
+ echo "árbol dado de alta con identidad propia:"
2168
+ echo " raíz: $wt"
2169
+ echo " agente: $newkey"
2170
+ echo " proyecto: $(runtime_credentials_field_at "$localfile" project_id)"
2171
+ [ -n "$mainkey" ] && echo " (distinto del clon principal: $mainkey — su fichero no se tocó)"
2172
+ echo "Desde aquí el claim, los gates y el heartbeat son de ESTE árbol. Siguiente paso:"
2173
+ echo " bash .claude/hooks/build/slice-ops.sh claim"
2174
+ return $OPS_RC_OK
2175
+ }
2176
+
1616
2177
  SUB="${1:-}"
1617
2178
  [ $# -gt 0 ] && shift
2179
+
2180
+ # [#74] Compuerta de identidad ANTES de despachar. Las operaciones de ESCRITURA — todo lo que
2181
+ # firma un acto con el `agent_key` o toca la cola/los ledgers — abortan si este árbol heredó la
2182
+ # identidad del clon principal. Las LECTURAS (`mode`, `next-step`, `status`, los `*-status`)
2183
+ # quedan fuera a propósito: son el diagnóstico de un worktree que aún no se dio de alta, y
2184
+ # `status` avisa de quién es el estado que enseña. `register` tampoco entra: es la salida.
2185
+ case " claim phase gate submit archive wiring progress checkpoint fact propose-asset propose-epic epic-writeback graph-sync escalate " in
2186
+ *" $SUB "*) ops_guard_own_identity "$SUB" || exit $? ;;
2187
+ esac
2188
+
1618
2189
  case "$SUB" in
1619
2190
  mode) ops_mode; exit 0 ;;
2191
+ register) cmd_register "$@"; exit $? ;;
1620
2192
  claim) cmd_claim "$@"; exit $? ;;
1621
2193
  next-step) cmd_next_step; exit $? ;;
1622
2194
  phase) cmd_phase "$@"; exit $? ;;
@@ -1631,6 +2203,8 @@ case "$SUB" in
1631
2203
  propose-epic) cmd_propose_epic "$@"; exit $? ;;
1632
2204
  epic-status) cmd_epic_status "$@"; exit $? ;;
1633
2205
  epic-writeback) cmd_epic_writeback "$@"; exit $? ;;
2206
+ graph-sync) cmd_graph_sync "$@"; exit $? ;;
2207
+ graph-sync-status) cmd_graph_sync_status "$@"; exit $? ;;
1634
2208
  status) cmd_status "$@"; exit $? ;;
1635
2209
  escalate) cmd_escalate "$@"; exit $? ;;
1636
2210
  ""|-h|--help|help) usage; exit $OPS_RC_USAGE ;;