@trycore/spec-build-harness 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -112,8 +123,19 @@ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
112
123
  # (`EP-XXX`) al aprobar y un humano aprueba: por eso hay una ruta de creación y otra de
113
124
  # consulta, y ninguna de publicación. Mismo patrón que las propuestas de asset
114
125
  # (`ops_path_proposals`), que es su precedente en este cliente.
115
- ops_path_epic_proposals() { echo "/projects/$1/epic-proposals"; }
116
- ops_path_epic_proposal() { echo "/projects/$1/epic-proposals/$2"; }
126
+ # [#70] El carril del AGENTE lleva `/agent/`: `/projects/{id}/epic-proposals` (sin él) es la
127
+ # vista de CONSOLA — GET con JWT de usuario — y responde `405 Allow: GET` a un POST con token
128
+ # de proyecto. Las dos superficies no se comparten por diseño (hub CON-1).
129
+ ops_path_epic_proposals() { echo "/projects/$1/agent/epic-proposals"; }
130
+ ops_path_epic_proposal() { echo "/projects/$1/agent/epic-proposals/$2"; }
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"; }
117
139
 
118
140
  ops_mode() { runtime_mode; }
119
141
 
@@ -262,39 +284,96 @@ PY
262
284
  runtime_outbox_enforce_cap >/dev/null
263
285
  }
264
286
 
265
- # ops_mirror <kind> <payload_file> — ingesta espejo del modo `dual` (spec §4.1-D). El
266
- # fichero local sigue siendo primario: un rechazo del reducer NO bloquea el trabajo, queda
267
- # 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.
268
299
  ops_mirror() {
269
- 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
270
309
  pid="$(runtime_field project_id)"
271
- [ -n "$pid" ] || return 5
272
- command -v python3 >/dev/null 2>&1 || return 5
273
- tmp="$(mktemp)" || return 5
274
- python3 - "$kind" "$pfile" > "$tmp" <<'PY' 2>/dev/null
275
- import json,sys,datetime
276
- 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]
277
322
  try:
278
323
  payload=json.load(open(pfile))
279
324
  except Exception:
280
325
  payload={}
281
- print(json.dumps({"kind":kind,"payload":payload,"origin":"mirror",
282
- "occurred_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
283
- 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))
284
332
  PY
285
333
  body="$(cat "$tmp" 2>/dev/null)"
286
334
  rm -f "$tmp"
287
- [ -n "$body" ] || return 5
335
+ [ -n "$body" ] || return $OPS_RC_OFFLINE
288
336
  resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
289
337
  status="$(runtime_http_status)"
290
338
  case "$status" in
291
- 200|201|202) return 0 ;;
339
+ 200|201|202) _ops_mirror_report "$event_type" "$resp" ;;
292
340
  409|422)
293
- 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
294
344
  echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
295
- return 6 ;;
296
- *) 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 ;;
297
374
  esac
375
+ echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
376
+ return $OPS_RC_REJECTED
298
377
  }
299
378
 
300
379
  # ── Ledger local de propuestas de épica [#62] ────────────────────────────────────────
@@ -305,11 +384,13 @@ PY
305
384
  # instante); `proposal_id` llega después, con el ack, y puede no llegar nunca (offline).
306
385
  ops_epic_ledger_path() { echo "$(config_root)/.claude/state/epic-proposals.json"; }
307
386
 
308
- # ops_epic_ledger_read — imprime {"proposals":[…]}; nunca lanza.
309
- ops_epic_ledger_read() {
310
- 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() {
311
392
  command -v python3 >/dev/null 2>&1 || { echo '{"proposals":[]}'; return; }
312
- python3 - "$f" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
393
+ python3 - "$1" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
313
394
  import json,sys
314
395
  try:
315
396
  d=json.load(open(sys.argv[1]))
@@ -320,12 +401,11 @@ except Exception:
320
401
  PY
321
402
  }
322
403
 
323
- # 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
324
405
  # entrada NO menciona se conservan (así un `epic-status` puede fijar el estado sin borrar el
325
406
  # borrador que guardó `propose-epic`). Escritura atómica. rc 0/1.
326
- ops_epic_ledger_upsert() {
327
- local entry="$1" f
328
- f="$(ops_epic_ledger_path)"
407
+ _ops_ledger_upsert() {
408
+ local f="$1" entry="$2"
329
409
  mkdir -p "$(dirname "$f")"
330
410
  command -v python3 >/dev/null 2>&1 || return 1
331
411
  python3 - "$f" "$entry" <<'PY' 2>/dev/null || return 1
@@ -348,7 +428,7 @@ for i,p in enumerate(ps):
348
428
  else:
349
429
  ps.append(e)
350
430
  dirn=os.path.dirname(path) or "."
351
- fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".epic-proposals.",suffix=".tmp")
431
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".ledger.",suffix=".tmp")
352
432
  try:
353
433
  with os.fdopen(fd,"w") as out:
354
434
  json.dump({"proposals":ps},out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
@@ -360,6 +440,37 @@ except Exception:
360
440
  PY
361
441
  }
362
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
+
363
474
  # ops_epic_ack_read <client_event_id> — el ack que dejó el carril directo, o "".
364
475
  ops_epic_ack_read() {
365
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)"