@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.
@@ -21,6 +21,32 @@ OPS_RC_NO_SLICE=4
21
21
  OPS_RC_OFFLINE=5
22
22
  OPS_RC_REJECTED=6
23
23
  OPS_RC_NO_WORK=7
24
+ OPS_RC_NOT_MIRRORABLE=8
25
+ OPS_RC_NO_IDENTITY=9
26
+
27
+ # ── Guard de identidad heredada [#74] ────────────────────────────────────────────────
28
+ # Un worktree sin `.claude/state/runtime.credentials` propio hereda el del clon principal por
29
+ # el fallback de `config_root()`: lee bien, pero al ESCRIBIR suplanta — reclama con el
30
+ # `agent_key` ajeno, reporta al hub los hashes del árbol equivocado y comparte su heartbeat.
31
+ # El hub reparte dos épicas a dos agentes sin objeción; el bloqueo del multi-agente en una
32
+ # misma máquina siempre estuvo aquí, en el cliente. Por eso el guard es fail-FAST y no
33
+ # fail-open: la regla general de este cliente («ante la duda, no bloquees el trabajo local»)
34
+ # protege del runtime caído, no de mandar actos firmados con la identidad de otro.
35
+ #
36
+ # ops_guard_own_identity <subcomando> — rc 0 si este árbol tiene identidad propia;
37
+ # OPS_RC_NO_IDENTITY (9) con mensaje accionable si la heredó.
38
+ ops_guard_own_identity() {
39
+ local sub="${1:-esta operación}" main
40
+ config_is_inherited_root || return 0
41
+ main="$(config_main_clone_root)"
42
+ echo "⛔ este worktree no tiene identidad propia: «${sub}» operaría como el agente del clon" >&2
43
+ echo " principal (${main:-?}) — su agent_key, su lease, sus hashes de contexto." >&2
44
+ echo " Da de alta este árbol con un token de agente NUEVO emitido por el ADMIN:" >&2
45
+ echo " bash .claude/hooks/build/slice-ops.sh register" >&2
46
+ echo " (el token se pide por stdin, nunca por argumento). Las lecturas — \`status\`," >&2
47
+ echo " \`next-step\`, \`mode\` — siguen funcionando con el contexto heredado." >&2
48
+ return $OPS_RC_NO_IDENTITY
49
+ }
24
50
 
25
51
  # Tope de texto libre que el cliente manda al servidor (evidencias, notas): el protocolo §8
26
52
  # dice que el servidor rechaza payloads sobredimensionados; se trunca aquí, marcándolo.
@@ -40,6 +66,16 @@ OPS_RELEASE_GATES="security smell ux coherence stack_arch integration"
40
66
  # (orden estricto, domain.py:400-407) — de aquí salen las cadenas que emite `phase`.
41
67
  OPS_SLICE_PHASES="dor change red green refactor smoke api data dod pr"
42
68
 
69
+ # Enumeración NORMATIVA de la ingesta espejo (`TIPOS_DE_ESPEJO` del hub, derivada allí de los
70
+ # schemas que heredan de `_TransicionDeEspejo`). Se replica aquí para no gastar una llamada en
71
+ # algo que el hub va a rechazar por elemento — y el test la compara contra esta misma lista, así
72
+ # que ampliarla en el hub obliga a ampliarla aquí (lección de #44: dos listas del mismo hecho
73
+ # divergen tarde o temprano, y la que calla es la que miente).
74
+ # `release_verdict_reported` y `front_integration_reported` NO están, y no es un olvido: no
75
+ # existen en el catálogo del hub. Sus canales de espejo son trabajo hub-side (tipo nuevo con su
76
+ # base `_TransicionDeEspejo` y su rama de reducer), no algo que este cliente pueda arreglar.
77
+ OPS_MIRROR_TYPES="slice_opened phase_advanced phase_reverted gate_verdict wiring_item_updated wiring_checklist_seeded checkpoint_recorded progress_noted handoff_recorded"
78
+
43
79
  # ops_phase_idx <fase> — índice de la fase en OPS_SLICE_PHASES; -1 si no es fase del hub.
44
80
  ops_phase_idx() {
45
81
  local i=0 p
@@ -118,6 +154,14 @@ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
118
154
  ops_path_epic_proposals() { echo "/projects/$1/agent/epic-proposals"; }
119
155
  ops_path_epic_proposal() { echo "/projects/$1/agent/epic-proposals/$2"; }
120
156
 
157
+ # [EP-OR-17] Re-sync del grafo (HU-OR-72/73). El arnés PROPONE el bundle construido desde los
158
+ # docs de discovery, el hub calcula el delta y un HUMANO aprueba en la consola: nada se aplica
159
+ # por esta ruta. Mismo carril `/agent/` que las propuestas de épica y por la misma razón — la
160
+ # ruta sin `/agent/` es la superficie humana (sesión + PDP) y a un bearer de agente le responde
161
+ # 403 auditado, no 405: las dos superficies no se comparten por diseño.
162
+ ops_path_graph_sync_proposals() { echo "/projects/$1/agent/graph-sync-proposals"; }
163
+ ops_path_graph_sync_proposal() { echo "/projects/$1/agent/graph-sync-proposals/$2"; }
164
+
121
165
  ops_mode() { runtime_mode; }
122
166
 
123
167
  # ops_slice_id — id del slice activo según la caché de proyección ("" si no hay).
@@ -265,41 +309,98 @@ PY
265
309
  runtime_outbox_enforce_cap >/dev/null
266
310
  }
267
311
 
268
- # ops_mirror <kind> <payload_file> — ingesta espejo del modo `dual` (spec §4.1-D). El
269
- # fichero local sigue siendo primario: un rechazo del reducer NO bloquea el trabajo, queda
270
- # como discrepancia del comparador. rc 0 aceptado · 5 no se pudo (red) · 6 rechazado.
312
+ # ops_mirror <event_type> <payload_file> [epic_code] — ingesta espejo del modo `dual`
313
+ # (spec §4.1-D). El fichero local sigue siendo primario: un rechazo del reducer NO bloquea el
314
+ # trabajo, queda como discrepancia del comparador.
315
+ # rc 0 aplicado o duplicado · 5 no se pudo (red, sin epic_code, sin python3) · 6 rechazado
316
+ # · 8 el tipo no es espejable.
317
+ #
318
+ # [EP-OR-17] El cuerpo es `MirrorTransitionsIn`: `{"transitions":[{client_event_id, epic_code,
319
+ # event_type, payload}]}`. El `{kind, payload, origin, occurred_at}` que se mandaba antes no
320
+ # existe en el contrato — el 100 % de lo que el modo dual espejaba era un 422. El `occurred_at`
321
+ # tampoco viaja, y no por descuido: los relojes de cliente entran SOLO por el import; aquí
322
+ # estampa el servidor. El `origin` tampoco: la marca de origen-espejo la pone el hub, y lo que
323
+ # el cliente dijera no elegiría el origen de todos modos.
271
324
  ops_mirror() {
272
- local kind="$1" pfile="$2" pid body resp status tmp
325
+ local event_type="$1" pfile="$2" epic_code="${3:-}" pid body resp status tmp
326
+ case " $OPS_MIRROR_TYPES " in
327
+ *" $event_type "*) : ;;
328
+ *)
329
+ echo "⚠ «${event_type}» está fuera de la enumeración normativa del catálogo de espejo del hub:" >&2
330
+ echo " no se envía (viajaría solo para volver rechazado por elemento). Espejables:" >&2
331
+ echo " $OPS_MIRROR_TYPES" >&2
332
+ return $OPS_RC_NOT_MIRRORABLE ;;
333
+ esac
273
334
  pid="$(runtime_field project_id)"
274
- [ -n "$pid" ] || return 5
275
- command -v python3 >/dev/null 2>&1 || return 5
276
- tmp="$(mktemp)" || return 5
277
- python3 - "$kind" "$pfile" > "$tmp" <<'PY' 2>/dev/null
278
- import json,sys,datetime
279
- kind,pfile=sys.argv[1],sys.argv[2]
335
+ [ -n "$pid" ] || return $OPS_RC_OFFLINE
336
+ [ -n "$epic_code" ] || epic_code="$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)"
337
+ if [ -z "$epic_code" ]; then
338
+ echo " «${event_type}» no espejado: sin epic_code (el hub lo exige y el lote sería un 422)." >&2
339
+ echo " Sale de active_slice.epica del build-state local: abre un slice antes de espejar." >&2
340
+ return $OPS_RC_OFFLINE
341
+ fi
342
+ command -v python3 >/dev/null 2>&1 || return $OPS_RC_OFFLINE
343
+ tmp="$(mktemp)" || return $OPS_RC_OFFLINE
344
+ python3 - "$event_type" "$pfile" "$epic_code" > "$tmp" <<'PY' 2>/dev/null
345
+ import json,sys,uuid
346
+ event_type,pfile,epic_code=sys.argv[1],sys.argv[2],sys.argv[3]
280
347
  try:
281
348
  payload=json.load(open(pfile))
282
349
  except Exception:
283
350
  payload={}
284
- print(json.dumps({"kind":kind,"payload":payload,"origin":"mirror",
285
- "occurred_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
286
- ensure_ascii=False))
351
+ if not isinstance(payload,dict):
352
+ payload={}
353
+ print(json.dumps({"transitions":[{"client_event_id":str(uuid.uuid4()),
354
+ "epic_code":epic_code,
355
+ "event_type":event_type,
356
+ "payload":payload}]},ensure_ascii=False))
287
357
  PY
288
358
  body="$(cat "$tmp" 2>/dev/null)"
289
359
  rm -f "$tmp"
290
- [ -n "$body" ] || return 5
360
+ [ -n "$body" ] || return $OPS_RC_OFFLINE
291
361
  resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
292
362
  status="$(runtime_http_status)"
293
363
  case "$status" in
294
- 200|201|202) return 0 ;;
364
+ 200|201|202) _ops_mirror_report "$event_type" "$resp" ;;
295
365
  409|422)
296
- echo "⚠ el espejo rechazó «${kind}»: $resp" >&2
366
+ # 409 = el cutover apagó la ingesta espejo para este proyecto; 422 = el lote no cumple el
367
+ # borde. Los dos son del LOTE entero, no de un elemento: no hay reporte que leer.
368
+ echo "⚠ el espejo rechazó «${event_type}» (HTTP $status): $resp" >&2
297
369
  echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
298
- return 6 ;;
299
- *) return 5 ;;
370
+ return $OPS_RC_REJECTED ;;
371
+ *) return $OPS_RC_OFFLINE ;;
300
372
  esac
301
373
  }
302
374
 
375
+ # _ops_mirror_report <event_type> <respuesta> — lee el reporte POR ELEMENTO (HU-OR-56 E3).
376
+ # rc 0 aplicado o duplicado · 6 rechazado, con el motivo del hub a la vista: volcar el cuerpo
377
+ # crudo escondía justo la frase que dice qué hay que arreglar.
378
+ _ops_mirror_report() {
379
+ local event_type="$1" reason
380
+ command -v python3 >/dev/null 2>&1 || return $OPS_RC_OK
381
+ reason="$(printf '%s' "$2" | python3 -c '
382
+ import json,sys
383
+ try:
384
+ d=json.load(sys.stdin)
385
+ rs=d.get("results") or []
386
+ sys.stdout.write(next((r.get("reason") or "rechazado sin motivo registrado")
387
+ for r in rs if r.get("status")=="rejected"))
388
+ except Exception:
389
+ pass
390
+ ' 2>/dev/null)"
391
+ [ -n "$reason" ] || return $OPS_RC_OK
392
+ echo "⚠ el espejo rechazó «${event_type}»: $reason" >&2
393
+ case "$reason" in
394
+ *"no existe en el grafo"*)
395
+ # Este es EL rechazo del piloto: el grafo del hub se quedó en la foto del import inicial
396
+ # y toda transición de una épica posterior rebota. Ahora hay una salida que ofrecer.
397
+ echo " El grafo del hub no conoce esa épica. Propón el re-sync con" >&2
398
+ echo " \`slice-ops.sh graph-sync --file epics.json\` y pide su aprobación en la consola." >&2 ;;
399
+ esac
400
+ echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
401
+ return $OPS_RC_REJECTED
402
+ }
403
+
303
404
  # ── Ledger local de propuestas de épica [#62] ────────────────────────────────────────
304
405
  # Memoria del ciclo «propongo → un humano aprueba → escribo el fichero con el código del
305
406
  # hub». Vive fuera de build-state.json a propósito: en modo `runtime` ese fichero no es la
@@ -308,11 +409,13 @@ PY
308
409
  # instante); `proposal_id` llega después, con el ack, y puede no llegar nunca (offline).
309
410
  ops_epic_ledger_path() { echo "$(config_root)/.claude/state/epic-proposals.json"; }
310
411
 
311
- # ops_epic_ledger_read — imprime {"proposals":[…]}; nunca lanza.
312
- ops_epic_ledger_read() {
313
- local f; f="$(ops_epic_ledger_path)"
412
+ # _ops_ledger_read <fichero> — imprime {"proposals":[…]}; nunca lanza.
413
+ # [EP-OR-17] Base común del ledger de propuestas de épica y del de re-sync del grafo: son la
414
+ # misma estructura con otra ruta, y duplicar 60 líneas de python3 garantizaba que una de las
415
+ # dos copias se quedara atrás.
416
+ _ops_ledger_read() {
314
417
  command -v python3 >/dev/null 2>&1 || { echo '{"proposals":[]}'; return; }
315
- python3 - "$f" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
418
+ python3 - "$1" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
316
419
  import json,sys
317
420
  try:
318
421
  d=json.load(open(sys.argv[1]))
@@ -323,12 +426,11 @@ except Exception:
323
426
  PY
324
427
  }
325
428
 
326
- # ops_epic_ledger_upsert <entrada-json> — upsert por client_event_id: las claves que la
429
+ # _ops_ledger_upsert <fichero> <entrada-json> — upsert por client_event_id: las claves que la
327
430
  # entrada NO menciona se conservan (así un `epic-status` puede fijar el estado sin borrar el
328
431
  # borrador que guardó `propose-epic`). Escritura atómica. rc 0/1.
329
- ops_epic_ledger_upsert() {
330
- local entry="$1" f
331
- f="$(ops_epic_ledger_path)"
432
+ _ops_ledger_upsert() {
433
+ local f="$1" entry="$2"
332
434
  mkdir -p "$(dirname "$f")"
333
435
  command -v python3 >/dev/null 2>&1 || return 1
334
436
  python3 - "$f" "$entry" <<'PY' 2>/dev/null || return 1
@@ -351,7 +453,7 @@ for i,p in enumerate(ps):
351
453
  else:
352
454
  ps.append(e)
353
455
  dirn=os.path.dirname(path) or "."
354
- fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".epic-proposals.",suffix=".tmp")
456
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".ledger.",suffix=".tmp")
355
457
  try:
356
458
  with os.fdopen(fd,"w") as out:
357
459
  json.dump({"proposals":ps},out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
@@ -363,6 +465,37 @@ except Exception:
363
465
  PY
364
466
  }
365
467
 
468
+ ops_epic_ledger_read() { _ops_ledger_read "$(ops_epic_ledger_path)"; }
469
+ ops_epic_ledger_upsert() { _ops_ledger_upsert "$(ops_epic_ledger_path)" "$1"; }
470
+
471
+ # ── Ledger local del re-sync del grafo [EP-OR-17] ────────────────────────────────────
472
+ # Fichero PROPIO, no una sección del de épicas: un re-sync no es una propuesta de épica, y
473
+ # mezclarlos haría que `epic-status` hablara de grafos y `graph-sync-status` de épicas.
474
+ ops_graph_sync_ledger_path() { echo "$(config_root)/.claude/state/graph-sync-proposals.json"; }
475
+ ops_graph_sync_ledger_read() { _ops_ledger_read "$(ops_graph_sync_ledger_path)"; }
476
+ ops_graph_sync_ledger_upsert() { _ops_ledger_upsert "$(ops_graph_sync_ledger_path)" "$1"; }
477
+
478
+ # ops_graph_sync_cid — `client_event_id` DETERMINISTA de la próxima propuesta de re-sync:
479
+ # `<agent_key>:graph-sync:<n>`, con n = entradas del ledger + 1.
480
+ #
481
+ # Determinista es el punto: el reintento tras un 409 de grafo rancio reusa exactamente este
482
+ # cid y el hub deduplica, en vez de acabar con dos propuestas del mismo grafo esperando
483
+ # aprobación humana. El contrato acota el campo a 64 caracteres, así que una `agent_key` larga
484
+ # se sustituye por su sha256 truncado — sigue siendo estable para el mismo agente, que es lo
485
+ # único que la idempotencia necesita.
486
+ ops_graph_sync_cid() {
487
+ local ak n
488
+ ak="$(runtime_field agent_key)"
489
+ [ -n "$ak" ] || ak="agente"
490
+ n="$(ops_graph_sync_ledger_read | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("proposals") or []) + 1)' 2>/dev/null)"
491
+ case "$n" in ''|*[!0-9]*) n=1 ;; esac
492
+ if [ "${#ak}" -gt 30 ]; then
493
+ ak="$(printf '%s' "$ak" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.read().encode()).hexdigest()[:16])' 2>/dev/null)"
494
+ [ -n "$ak" ] || ak="agente"
495
+ fi
496
+ echo "${ak}:graph-sync:${n}"
497
+ }
498
+
366
499
  # ops_epic_ack_read <client_event_id> — el ack que dejó el carril directo, o "".
367
500
  ops_epic_ack_read() {
368
501
  local f; f="$(runtime_outbox_dir)/acks/$1.json"
@@ -24,6 +24,7 @@ uso: release-ops.sh <subcomando> [opciones]
24
24
  [--resmoke pass|fail] [--evidence T | --evidence-file F]
25
25
 
26
26
  códigos de salida: 0 ok · 2 uso · 3 legacy · 5 offline · 6 rechazado
27
+ · 9 identidad heredada (da de alta el worktree: `slice-ops.sh register`)
27
28
  USAGE
28
29
  }
29
30
 
@@ -35,7 +36,7 @@ _rel_evidence_json() {
35
36
 
36
37
  # ── verdict ──────────────────────────────────────────────────────────────────────────
37
38
  cmd_verdict() {
38
- local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status pfile rel_status closed_by
39
+ local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status rel_status closed_by
39
40
  if [ -z "$line" ] || [ -z "$gate" ] || [ -z "$estado" ]; then usage; return $OPS_RC_USAGE; fi
40
41
  shift 3
41
42
  while [ $# -gt 0 ]; do
@@ -59,9 +60,13 @@ cmd_verdict() {
59
60
  [ "$mode" = "legacy" ] && { echo "LEGACY: escribe el gate en releases[] del fichero"; return $OPS_RC_LEGACY; }
60
61
  payload="{\"release_line\":$(ops_json_str "$line"),\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"verdict\":$(ops_json_str "$(printf %s "$estado" | tr a-z A-Z)"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}"
61
62
  if [ "$mode" = "dual" ]; then
62
- pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
63
- ops_mirror release_verdict_reported "$pfile" || echo "⚠ veredicto de release no espejado; el comparador dual lo marcará" >&2
64
- rm -f "$pfile"
63
+ # [EP-OR-17] El hub NO tiene canal de espejo para los veredictos de release:
64
+ # `release_verdict_reported` no existe en su catálogo de eventos (`TIPOS_DE_ESPEJO` solo
65
+ # cubre transiciones de slice) y su superficie síncrona es de gobierno humano. Mandarlo era
66
+ # gastar una llamada para recibir un rechazo por elemento y un aviso genérico que mandaba a
67
+ # revisar una red que funcionaba. Se dice lo que pasa y se sigue: el fichero es primario.
68
+ echo "DUAL: veredicto anotado en local. El hub todavía no espeja veredictos de release"
69
+ echo "(no hay tipo en su catálogo): quedará reflejado al pasar a modo \`runtime\`."
65
70
  return $OPS_RC_OK
66
71
  fi
67
72
  resp="$(runtime_post "$(ops_path_release_verdicts "$(ops_urlenc "$line")")" "$payload")"
@@ -131,11 +136,18 @@ HINT
131
136
  }
132
137
 
133
138
  # ── front-integration ────────────────────────────────────────────────────────────────
134
- # Cada worktree se registra como AGENTE PROPIO (mismo token de proyecto, `agent_key`
135
- # distinto): reporta la integración de SU miembro. Planificar, abrir, drenar y cerrar el
136
- # front siguen siendo humanos.
139
+ # Cada worktree se registra como AGENTE PROPIO y reporta la integración de SU miembro.
140
+ # Planificar, abrir, drenar y cerrar el front siguen siendo humanos.
141
+ #
142
+ # [#74] «Mismo token de proyecto, agent_key distinto» era falso y estuvo escrito aquí y en la
143
+ # skill: el hub NO crea identidad en `POST /agents/register` — el `agent_key` es el `agent_id`
144
+ # que el ADMIN grabó en el token al emitirlo. Un token por agente, o los dos worktrees son el
145
+ # mismo agente. La compuerta va en el despacho (`ops_guard_own_identity`), no aquí: el
146
+ # `[ -n "$akey" ]` de más abajo era inefectivo por construcción — leía la clave vía
147
+ # `config_root()`, que en un worktree sin credenciales ya había heredado la del principal, así
148
+ # que solo saltaba si el principal TAMPOCO estaba registrado.
137
149
  cmd_front_integration() {
138
- local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status pfile
150
+ local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status
139
151
  [ -n "$front" ] || { usage; return $OPS_RC_USAGE; }
140
152
  shift
141
153
  while [ $# -gt 0 ]; do
@@ -159,13 +171,17 @@ cmd_front_integration() {
159
171
  [ "$mode" = "legacy" ] && { echo "LEGACY: actualiza parallel_front.members[] en el fichero"; return $OPS_RC_LEGACY; }
160
172
  payload="{\"merge_status\":$(ops_json_str "$merge"),\"re_smoke\":{\"status\":$(ops_json_str "$resmoke"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}}"
161
173
  if [ "$mode" = "dual" ]; then
162
- pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
163
- ops_mirror front_integration_reported "$pfile" || echo "⚠ integración de front no espejada; el comparador dual la marcará" >&2
164
- rm -f "$pfile"
174
+ # Mismo caso que los veredictos de release: `front_integration_reported` no existe en el
175
+ # catálogo del hub y el agregado `front` no entra por la ingesta espejo.
176
+ echo "DUAL: integración anotada en local. El hub todavía no espeja integraciones de front"
177
+ echo "(no hay tipo en su catálogo): quedará reflejada al pasar a modo \`runtime\`."
165
178
  return $OPS_RC_OK
166
179
  fi
167
180
  akey="$(runtime_field agent_key)"
168
- [ -n "$akey" ] || { echo "⛔ este worktree no está registrado como agente: corre \`trycore-build init\` en él" >&2; return $OPS_RC_REJECTED; }
181
+ [ -n "$akey" ] || {
182
+ echo "⛔ este árbol no tiene \`agent_key\`: da de alta el agente con" >&2
183
+ echo " \`bash .claude/hooks/build/slice-ops.sh register\` (token propio del ADMIN)." >&2
184
+ return $OPS_RC_REJECTED; }
169
185
  resp="$(runtime_post "$(ops_path_front_integration "$(ops_urlenc "$front")" "$(ops_urlenc "$akey")")" "$payload")"
170
186
  status="$(runtime_http_status)"
171
187
  case "$status" in
@@ -187,6 +203,16 @@ cmd_front_integration() {
187
203
 
188
204
  SUB="${1:-}"
189
205
  [ $# -gt 0 ] && shift
206
+
207
+ # [#74] Reportar la integración de un miembro del front es EL acto por-agente de este fichero
208
+ # (la ruta lleva el `agent_key` en el path): desde un árbol con identidad heredada estaría
209
+ # firmando por el agente del clon principal. `verdict` queda fuera de la compuerta a propósito
210
+ # — un veredicto de release es del proyecto, no del agente, y se reporta desde el árbol
211
+ # principal; `close-hint` no toca la red.
212
+ case "$SUB" in
213
+ front-integration) ops_guard_own_identity "front-integration" || exit $? ;;
214
+ esac
215
+
190
216
  case "$SUB" in
191
217
  verdict) cmd_verdict "$@"; exit $? ;;
192
218
  close-hint) cmd_close_hint "$@"; exit $? ;;
@@ -11,6 +11,9 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
11
  source "$HERE/lib/runtime-client.sh"
12
12
  source "$HERE/lib/projection.sh"
13
13
  source "$HERE/lib/agent-context.sh"
14
+ # [#74] La versión del arnés y el catálogo de asset types viven en lib/harness-meta.sh:
15
+ # `slice-ops.sh register` los necesita exactamente iguales para dar de alta un worktree.
16
+ source "$HERE/lib/harness-meta.sh"
14
17
 
15
18
  cat >/dev/null 2>&1 # consumir el payload del hook (protocolo); no se usa aquí
16
19
 
@@ -19,67 +22,8 @@ MODE="$(runtime_mode)"
19
22
  command -v python3 >/dev/null 2>&1 || exit 0
20
23
  [ -n "$(runtime_url)" ] || exit 0 # sin `trycore-build init` (sub-slice D) no hay runtime
21
24
 
22
- # sst_pkg_root — raíz del PAQUETE (no del proyecto). Los hooks se instalan como symlinks
23
- # por fichero (linkChildren), así que hay que resolver la cadena de enlaces a mano:
24
- # `readlink -f` no es portable al BSD readlink de macOS.
25
- sst_pkg_root() {
26
- local src="${BASH_SOURCE[0]}" target i=0
27
- while [ -L "$src" ] && [ "$i" -lt 10 ]; do
28
- target="$(readlink "$src" 2>/dev/null)" || break
29
- case "$target" in
30
- /*) src="$target" ;;
31
- *) src="$(dirname "$src")/$target" ;;
32
- esac
33
- i=$((i + 1))
34
- done
35
- (cd "$(dirname "$src")/../.." >/dev/null 2>&1 && pwd -P) 2>/dev/null
36
- }
37
-
38
- # sst_harness_version — versión instalada en el consumidor; si no, la del paquete.
39
- sst_harness_version() {
40
- local f pkg
41
- f="$(config_root)/.claude/.build-harness-version"
42
- if [ -f "$f" ]; then
43
- tr -d ' \n' < "$f"
44
- return
45
- fi
46
- pkg="$(sst_pkg_root)"
47
- if [ -n "$pkg" ] && [ -f "$pkg/VERSION" ]; then
48
- tr -d ' \n' < "$pkg/VERSION"
49
- return
50
- fi
51
- echo "0.0.0"
52
- }
53
-
54
- # sst_asset_types — el array `types` de asset-types.json del paquete (protocolo §6).
55
- # Cadena de resolución: plugin > copia en .claude/ (la siembra es del sub-slice D) >
56
- # paquete resuelto por symlink. Sin fichero: "[]" — el registro sigue funcionando.
57
- sst_asset_types() {
58
- local pkg candidates f
59
- pkg="$(sst_pkg_root)"
60
- candidates="${CLAUDE_PLUGIN_ROOT:-}/asset-types.json
61
- $(config_root)/.claude/asset-types.json
62
- ${pkg:-}/asset-types.json"
63
- while IFS= read -r f; do
64
- case "$f" in /asset-types.json|asset-types.json) continue ;; esac
65
- [ -f "$f" ] || continue
66
- python3 - "$f" <<'PY' 2>/dev/null && return
67
- import json,sys
68
- try:
69
- d=json.load(open(sys.argv[1]))
70
- t=d.get("types")
71
- print(json.dumps(t if isinstance(t,list) else [],ensure_ascii=False))
72
- except Exception:
73
- raise SystemExit(1)
74
- PY
75
- done <<EOS
76
- $candidates
77
- EOS
78
- echo "[]"
79
- }
80
-
81
- HV="$(sst_harness_version)"
82
- AT="$(sst_asset_types)"
25
+ HV="$(harness_version)"
26
+ AT="$(harness_asset_types)"
83
27
 
84
28
  runtime_register "$HV" "$AT"
85
29
  RC=$?