@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.
@@ -21,6 +21,7 @@ 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
24
25
 
25
26
  # Tope de texto libre que el cliente manda al servidor (evidencias, notas): el protocolo §8
26
27
  # dice que el servidor rechaza payloads sobredimensionados; se trunca aquí, marcándolo.
@@ -40,6 +41,16 @@ OPS_RELEASE_GATES="security smell ux coherence stack_arch integration"
40
41
  # (orden estricto, domain.py:400-407) — de aquí salen las cadenas que emite `phase`.
41
42
  OPS_SLICE_PHASES="dor change red green refactor smoke api data dod pr"
42
43
 
44
+ # Enumeración NORMATIVA de la ingesta espejo (`TIPOS_DE_ESPEJO` del hub, derivada allí de los
45
+ # schemas que heredan de `_TransicionDeEspejo`). Se replica aquí para no gastar una llamada en
46
+ # algo que el hub va a rechazar por elemento — y el test la compara contra esta misma lista, así
47
+ # que ampliarla en el hub obliga a ampliarla aquí (lección de #44: dos listas del mismo hecho
48
+ # divergen tarde o temprano, y la que calla es la que miente).
49
+ # `release_verdict_reported` y `front_integration_reported` NO están, y no es un olvido: no
50
+ # existen en el catálogo del hub. Sus canales de espejo son trabajo hub-side (tipo nuevo con su
51
+ # base `_TransicionDeEspejo` y su rama de reducer), no algo que este cliente pueda arreglar.
52
+ OPS_MIRROR_TYPES="slice_opened phase_advanced phase_reverted gate_verdict wiring_item_updated wiring_checklist_seeded checkpoint_recorded progress_noted handoff_recorded"
53
+
43
54
  # ops_phase_idx <fase> — índice de la fase en OPS_SLICE_PHASES; -1 si no es fase del hub.
44
55
  ops_phase_idx() {
45
56
  local i=0 p
@@ -118,6 +129,14 @@ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
118
129
  ops_path_epic_proposals() { echo "/projects/$1/agent/epic-proposals"; }
119
130
  ops_path_epic_proposal() { echo "/projects/$1/agent/epic-proposals/$2"; }
120
131
 
132
+ # [EP-OR-17] Re-sync del grafo (HU-OR-72/73). El arnés PROPONE el bundle construido desde los
133
+ # docs de discovery, el hub calcula el delta y un HUMANO aprueba en la consola: nada se aplica
134
+ # por esta ruta. Mismo carril `/agent/` que las propuestas de épica y por la misma razón — la
135
+ # ruta sin `/agent/` es la superficie humana (sesión + PDP) y a un bearer de agente le responde
136
+ # 403 auditado, no 405: las dos superficies no se comparten por diseño.
137
+ ops_path_graph_sync_proposals() { echo "/projects/$1/agent/graph-sync-proposals"; }
138
+ ops_path_graph_sync_proposal() { echo "/projects/$1/agent/graph-sync-proposals/$2"; }
139
+
121
140
  ops_mode() { runtime_mode; }
122
141
 
123
142
  # ops_slice_id — id del slice activo según la caché de proyección ("" si no hay).
@@ -265,39 +284,96 @@ PY
265
284
  runtime_outbox_enforce_cap >/dev/null
266
285
  }
267
286
 
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.
287
+ # ops_mirror <event_type> <payload_file> [epic_code] — ingesta espejo del modo `dual`
288
+ # (spec §4.1-D). El fichero local sigue siendo primario: un rechazo del reducer NO bloquea el
289
+ # trabajo, queda como discrepancia del comparador.
290
+ # rc 0 aplicado o duplicado · 5 no se pudo (red, sin epic_code, sin python3) · 6 rechazado
291
+ # · 8 el tipo no es espejable.
292
+ #
293
+ # [EP-OR-17] El cuerpo es `MirrorTransitionsIn`: `{"transitions":[{client_event_id, epic_code,
294
+ # event_type, payload}]}`. El `{kind, payload, origin, occurred_at}` que se mandaba antes no
295
+ # existe en el contrato — el 100 % de lo que el modo dual espejaba era un 422. El `occurred_at`
296
+ # tampoco viaja, y no por descuido: los relojes de cliente entran SOLO por el import; aquí
297
+ # estampa el servidor. El `origin` tampoco: la marca de origen-espejo la pone el hub, y lo que
298
+ # el cliente dijera no elegiría el origen de todos modos.
271
299
  ops_mirror() {
272
- local kind="$1" pfile="$2" pid body resp status tmp
300
+ local event_type="$1" pfile="$2" epic_code="${3:-}" pid body resp status tmp
301
+ case " $OPS_MIRROR_TYPES " in
302
+ *" $event_type "*) : ;;
303
+ *)
304
+ echo "⚠ «${event_type}» está fuera de la enumeración normativa del catálogo de espejo del hub:" >&2
305
+ echo " no se envía (viajaría solo para volver rechazado por elemento). Espejables:" >&2
306
+ echo " $OPS_MIRROR_TYPES" >&2
307
+ return $OPS_RC_NOT_MIRRORABLE ;;
308
+ esac
273
309
  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]
310
+ [ -n "$pid" ] || return $OPS_RC_OFFLINE
311
+ [ -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)"
312
+ if [ -z "$epic_code" ]; then
313
+ echo " «${event_type}» no espejado: sin epic_code (el hub lo exige y el lote sería un 422)." >&2
314
+ echo " Sale de active_slice.epica del build-state local: abre un slice antes de espejar." >&2
315
+ return $OPS_RC_OFFLINE
316
+ fi
317
+ command -v python3 >/dev/null 2>&1 || return $OPS_RC_OFFLINE
318
+ tmp="$(mktemp)" || return $OPS_RC_OFFLINE
319
+ python3 - "$event_type" "$pfile" "$epic_code" > "$tmp" <<'PY' 2>/dev/null
320
+ import json,sys,uuid
321
+ event_type,pfile,epic_code=sys.argv[1],sys.argv[2],sys.argv[3]
280
322
  try:
281
323
  payload=json.load(open(pfile))
282
324
  except Exception:
283
325
  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))
326
+ if not isinstance(payload,dict):
327
+ payload={}
328
+ print(json.dumps({"transitions":[{"client_event_id":str(uuid.uuid4()),
329
+ "epic_code":epic_code,
330
+ "event_type":event_type,
331
+ "payload":payload}]},ensure_ascii=False))
287
332
  PY
288
333
  body="$(cat "$tmp" 2>/dev/null)"
289
334
  rm -f "$tmp"
290
- [ -n "$body" ] || return 5
335
+ [ -n "$body" ] || return $OPS_RC_OFFLINE
291
336
  resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
292
337
  status="$(runtime_http_status)"
293
338
  case "$status" in
294
- 200|201|202) return 0 ;;
339
+ 200|201|202) _ops_mirror_report "$event_type" "$resp" ;;
295
340
  409|422)
296
- echo "⚠ el espejo rechazó «${kind}»: $resp" >&2
341
+ # 409 = el cutover apagó la ingesta espejo para este proyecto; 422 = el lote no cumple el
342
+ # borde. Los dos son del LOTE entero, no de un elemento: no hay reporte que leer.
343
+ echo "⚠ el espejo rechazó «${event_type}» (HTTP $status): $resp" >&2
297
344
  echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
298
- return 6 ;;
299
- *) return 5 ;;
345
+ return $OPS_RC_REJECTED ;;
346
+ *) return $OPS_RC_OFFLINE ;;
347
+ esac
348
+ }
349
+
350
+ # _ops_mirror_report <event_type> <respuesta> — lee el reporte POR ELEMENTO (HU-OR-56 E3).
351
+ # rc 0 aplicado o duplicado · 6 rechazado, con el motivo del hub a la vista: volcar el cuerpo
352
+ # crudo escondía justo la frase que dice qué hay que arreglar.
353
+ _ops_mirror_report() {
354
+ local event_type="$1" reason
355
+ command -v python3 >/dev/null 2>&1 || return $OPS_RC_OK
356
+ reason="$(printf '%s' "$2" | python3 -c '
357
+ import json,sys
358
+ try:
359
+ d=json.load(sys.stdin)
360
+ rs=d.get("results") or []
361
+ sys.stdout.write(next((r.get("reason") or "rechazado sin motivo registrado")
362
+ for r in rs if r.get("status")=="rejected"))
363
+ except Exception:
364
+ pass
365
+ ' 2>/dev/null)"
366
+ [ -n "$reason" ] || return $OPS_RC_OK
367
+ echo "⚠ el espejo rechazó «${event_type}»: $reason" >&2
368
+ case "$reason" in
369
+ *"no existe en el grafo"*)
370
+ # Este es EL rechazo del piloto: el grafo del hub se quedó en la foto del import inicial
371
+ # y toda transición de una épica posterior rebota. Ahora hay una salida que ofrecer.
372
+ echo " El grafo del hub no conoce esa épica. Propón el re-sync con" >&2
373
+ echo " \`slice-ops.sh graph-sync --file epics.json\` y pide su aprobación en la consola." >&2 ;;
300
374
  esac
375
+ echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
376
+ return $OPS_RC_REJECTED
301
377
  }
302
378
 
303
379
  # ── Ledger local de propuestas de épica [#62] ────────────────────────────────────────
@@ -308,11 +384,13 @@ PY
308
384
  # instante); `proposal_id` llega después, con el ack, y puede no llegar nunca (offline).
309
385
  ops_epic_ledger_path() { echo "$(config_root)/.claude/state/epic-proposals.json"; }
310
386
 
311
- # ops_epic_ledger_read — imprime {"proposals":[…]}; nunca lanza.
312
- ops_epic_ledger_read() {
313
- local f; f="$(ops_epic_ledger_path)"
387
+ # _ops_ledger_read <fichero> — imprime {"proposals":[…]}; nunca lanza.
388
+ # [EP-OR-17] Base común del ledger de propuestas de épica y del de re-sync del grafo: son la
389
+ # misma estructura con otra ruta, y duplicar 60 líneas de python3 garantizaba que una de las
390
+ # dos copias se quedara atrás.
391
+ _ops_ledger_read() {
314
392
  command -v python3 >/dev/null 2>&1 || { echo '{"proposals":[]}'; return; }
315
- python3 - "$f" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
393
+ python3 - "$1" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
316
394
  import json,sys
317
395
  try:
318
396
  d=json.load(open(sys.argv[1]))
@@ -323,12 +401,11 @@ except Exception:
323
401
  PY
324
402
  }
325
403
 
326
- # ops_epic_ledger_upsert <entrada-json> — upsert por client_event_id: las claves que la
404
+ # _ops_ledger_upsert <fichero> <entrada-json> — upsert por client_event_id: las claves que la
327
405
  # entrada NO menciona se conservan (así un `epic-status` puede fijar el estado sin borrar el
328
406
  # 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)"
407
+ _ops_ledger_upsert() {
408
+ local f="$1" entry="$2"
332
409
  mkdir -p "$(dirname "$f")"
333
410
  command -v python3 >/dev/null 2>&1 || return 1
334
411
  python3 - "$f" "$entry" <<'PY' 2>/dev/null || return 1
@@ -351,7 +428,7 @@ for i,p in enumerate(ps):
351
428
  else:
352
429
  ps.append(e)
353
430
  dirn=os.path.dirname(path) or "."
354
- fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".epic-proposals.",suffix=".tmp")
431
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".ledger.",suffix=".tmp")
355
432
  try:
356
433
  with os.fdopen(fd,"w") as out:
357
434
  json.dump({"proposals":ps},out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
@@ -363,6 +440,37 @@ except Exception:
363
440
  PY
364
441
  }
365
442
 
443
+ ops_epic_ledger_read() { _ops_ledger_read "$(ops_epic_ledger_path)"; }
444
+ ops_epic_ledger_upsert() { _ops_ledger_upsert "$(ops_epic_ledger_path)" "$1"; }
445
+
446
+ # ── Ledger local del re-sync del grafo [EP-OR-17] ────────────────────────────────────
447
+ # Fichero PROPIO, no una sección del de épicas: un re-sync no es una propuesta de épica, y
448
+ # mezclarlos haría que `epic-status` hablara de grafos y `graph-sync-status` de épicas.
449
+ ops_graph_sync_ledger_path() { echo "$(config_root)/.claude/state/graph-sync-proposals.json"; }
450
+ ops_graph_sync_ledger_read() { _ops_ledger_read "$(ops_graph_sync_ledger_path)"; }
451
+ ops_graph_sync_ledger_upsert() { _ops_ledger_upsert "$(ops_graph_sync_ledger_path)" "$1"; }
452
+
453
+ # ops_graph_sync_cid — `client_event_id` DETERMINISTA de la próxima propuesta de re-sync:
454
+ # `<agent_key>:graph-sync:<n>`, con n = entradas del ledger + 1.
455
+ #
456
+ # Determinista es el punto: el reintento tras un 409 de grafo rancio reusa exactamente este
457
+ # cid y el hub deduplica, en vez de acabar con dos propuestas del mismo grafo esperando
458
+ # aprobación humana. El contrato acota el campo a 64 caracteres, así que una `agent_key` larga
459
+ # se sustituye por su sha256 truncado — sigue siendo estable para el mismo agente, que es lo
460
+ # único que la idempotencia necesita.
461
+ ops_graph_sync_cid() {
462
+ local ak n
463
+ ak="$(runtime_field agent_key)"
464
+ [ -n "$ak" ] || ak="agente"
465
+ n="$(ops_graph_sync_ledger_read | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("proposals") or []) + 1)' 2>/dev/null)"
466
+ case "$n" in ''|*[!0-9]*) n=1 ;; esac
467
+ if [ "${#ak}" -gt 30 ]; then
468
+ ak="$(printf '%s' "$ak" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.read().encode()).hexdigest()[:16])' 2>/dev/null)"
469
+ [ -n "$ak" ] || ak="agente"
470
+ fi
471
+ echo "${ak}:graph-sync:${n}"
472
+ }
473
+
366
474
  # ops_epic_ack_read <client_event_id> — el ack que dejó el carril directo, o "".
367
475
  ops_epic_ack_read() {
368
476
  local f; f="$(runtime_outbox_dir)/acks/$1.json"
@@ -35,7 +35,7 @@ _rel_evidence_json() {
35
35
 
36
36
  # ── verdict ──────────────────────────────────────────────────────────────────────────
37
37
  cmd_verdict() {
38
- local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status pfile rel_status closed_by
38
+ local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status rel_status closed_by
39
39
  if [ -z "$line" ] || [ -z "$gate" ] || [ -z "$estado" ]; then usage; return $OPS_RC_USAGE; fi
40
40
  shift 3
41
41
  while [ $# -gt 0 ]; do
@@ -59,9 +59,13 @@ cmd_verdict() {
59
59
  [ "$mode" = "legacy" ] && { echo "LEGACY: escribe el gate en releases[] del fichero"; return $OPS_RC_LEGACY; }
60
60
  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
61
  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"
62
+ # [EP-OR-17] El hub NO tiene canal de espejo para los veredictos de release:
63
+ # `release_verdict_reported` no existe en su catálogo de eventos (`TIPOS_DE_ESPEJO` solo
64
+ # cubre transiciones de slice) y su superficie síncrona es de gobierno humano. Mandarlo era
65
+ # gastar una llamada para recibir un rechazo por elemento y un aviso genérico que mandaba a
66
+ # revisar una red que funcionaba. Se dice lo que pasa y se sigue: el fichero es primario.
67
+ echo "DUAL: veredicto anotado en local. El hub todavía no espeja veredictos de release"
68
+ echo "(no hay tipo en su catálogo): quedará reflejado al pasar a modo \`runtime\`."
65
69
  return $OPS_RC_OK
66
70
  fi
67
71
  resp="$(runtime_post "$(ops_path_release_verdicts "$(ops_urlenc "$line")")" "$payload")"
@@ -135,7 +139,7 @@ HINT
135
139
  # distinto): reporta la integración de SU miembro. Planificar, abrir, drenar y cerrar el
136
140
  # front siguen siendo humanos.
137
141
  cmd_front_integration() {
138
- local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status pfile
142
+ local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status
139
143
  [ -n "$front" ] || { usage; return $OPS_RC_USAGE; }
140
144
  shift
141
145
  while [ $# -gt 0 ]; do
@@ -159,9 +163,10 @@ cmd_front_integration() {
159
163
  [ "$mode" = "legacy" ] && { echo "LEGACY: actualiza parallel_front.members[] en el fichero"; return $OPS_RC_LEGACY; }
160
164
  payload="{\"merge_status\":$(ops_json_str "$merge"),\"re_smoke\":{\"status\":$(ops_json_str "$resmoke"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}}"
161
165
  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"
166
+ # Mismo caso que los veredictos de release: `front_integration_reported` no existe en el
167
+ # catálogo del hub y el agregado `front` no entra por la ingesta espejo.
168
+ echo "DUAL: integración anotada en local. El hub todavía no espeja integraciones de front"
169
+ echo "(no hay tipo en su catálogo): quedará reflejada al pasar a modo \`runtime\`."
165
170
  return $OPS_RC_OK
166
171
  fi
167
172
  akey="$(runtime_field agent_key)"