@trycore/spec-build-harness 0.13.0 → 0.14.1

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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/INSTALL.md +56 -3
  3. package/METODOLOGIA.md +6 -1
  4. package/README.md +46 -2
  5. package/VERSION +1 -1
  6. package/agents/build/dor-dod-gatekeeper.md +10 -3
  7. package/commands/build/epic.md +109 -0
  8. package/commands/build/onboard.md +34 -9
  9. package/commands/build/slice.md +4 -2
  10. package/commands/build/work.md +6 -2
  11. package/config/build-config.template.json +2 -1
  12. package/dist/commands/init.js +13 -0
  13. package/dist/commands/status.js +13 -1
  14. package/dist/lib/paths.js +1 -0
  15. package/dist/lib/runtime-client.js +20 -0
  16. package/docs/commands.md +3 -2
  17. package/docs/getting-started.md +11 -1
  18. package/docs/hooks.md +11 -1
  19. package/docs/runtime/guia-modo-dual-y-migracion.md +89 -9
  20. package/docs/runtime/protocolo-cliente-runtime.md +64 -0
  21. package/hooks/build/design-source-guard.sh +12 -1
  22. package/hooks/build/heartbeat.sh +64 -4
  23. package/hooks/build/lib/agent-context.sh +16 -7
  24. package/hooks/build/lib/config.sh +25 -1
  25. package/hooks/build/lib/runtime-client.sh +540 -9
  26. package/hooks/build/lib/runtime-ops.sh +108 -0
  27. package/hooks/build/scaffold-guard.sh +14 -1
  28. package/hooks/build/slice-ops.sh +498 -16
  29. package/package.json +1 -1
  30. package/scripts/tests/test-config.sh +41 -0
  31. package/scripts/tests/test-hooks-runtime.sh +198 -3
  32. package/scripts/tests/test-install.sh +23 -0
  33. package/scripts/tests/test-runtime-client.sh +500 -0
  34. package/scripts/tests/test-skill-ops.sh +456 -0
  35. package/skills/building-a-slice/references/dor.md +21 -9
  36. package/skills/building-a-slice/references/foundation-contract.md +4 -0
  37. package/state/README.md +12 -0
@@ -221,6 +221,28 @@ runtime_projection_path() {
221
221
  echo "$(config_root)/.claude/state/runtime-projection.json"
222
222
  }
223
223
 
224
+ # runtime_disconnected — 0 si este agente está en modo runtime y NO puede ver el
225
+ # estado del hub; 1 en cualquier otro caso.
226
+ #
227
+ # Existe para separar dos situaciones que hasta ahora colapsaban en el mismo
228
+ # `exit 0` de los guards y que significan lo contrario:
229
+ #
230
+ # auto-arme el proyecto todavía no usa el arnés (no hay credenciales) →
231
+ # no hay nada que vigilar y hay que dejar trabajar.
232
+ # desconectado el proyecto SÍ está registrado (hay credenciales) pero falta la
233
+ # proyección → el agente no sabe qué le manda el hub. Aquí callar
234
+ # es peor que estorbar: es exactamente el estado en el que dos
235
+ # workers reclaman el mismo trabajo o pisan los mismos ficheros.
236
+ #
237
+ # En legacy|dual devuelve 1 siempre: el fichero local es la fuente de verdad y no
238
+ # hay hub del que desconectarse.
239
+ runtime_disconnected() {
240
+ [ "$(runtime_mode)" = "runtime" ] || return 1
241
+ [ -f "$(runtime_credentials_path)" ] || return 1 # auto-arme legítimo
242
+ [ -f "$(runtime_projection_path)" ] && return 1
243
+ return 0
244
+ }
245
+
224
246
  runtime_projection_read() {
225
247
  local file; file="$(runtime_projection_path)"
226
248
  [ -f "$file" ] || { echo "{}"; return; }
@@ -257,6 +279,76 @@ except Exception:
257
279
  PY
258
280
  }
259
281
 
282
+ # _runtime_graph_version — versión de grafo conocida por la proyección local ("" si el hub no
283
+ # versiona o no hay caché). Vive aquí, y no en projection.sh, porque el despacho de la outbox
284
+ # la necesita y projection.sh depende de este fichero (invertirlo sería un ciclo de sourcing).
285
+ _runtime_graph_version() {
286
+ local f; f="$(runtime_projection_path)"
287
+ [ -f "$f" ] || { echo ""; return; }
288
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
289
+ python3 - "$f" <<'PY' 2>/dev/null || echo ""
290
+ import json,sys
291
+ try:
292
+ v=(json.load(open(sys.argv[1])).get("context") or {}).get("graph_version")
293
+ except Exception:
294
+ v=None
295
+ print("" if v is None else v)
296
+ PY
297
+ }
298
+
299
+ runtime_graph_status_path() { echo "$(config_root)/.claude/state/graph-status.json"; }
300
+
301
+ # runtime_graph_note_stale <local> <servidor> — deja constancia de que el hub rechazó una
302
+ # acción por grafo rancio [#63]. Es lo que hace VISIBLE el desfase en `status`: sin este
303
+ # rastro, el 409 se resolvería solo y el usuario nunca sabría que ocurrió.
304
+ # Una versión ausente (cadena vacía) se graba como `null`, NUNCA como 0: «no sé qué versión
305
+ # tenía» y «tenía la versión cero» son cosas distintas, y la segunda mentiría en `status`.
306
+ # [Ronda final] El fallo de escritura ya NO se traga: se avisa y se devuelve rc 1. Tragarlo
307
+ # borraba en silencio justo el rastro que esta función existe para dejar — el desfase habría
308
+ # ocurrido y nadie podría verlo.
309
+ runtime_graph_note_stale() {
310
+ local local_v="$1" server_v="$2" f
311
+ f="$(runtime_graph_status_path)"
312
+ mkdir -p "$(dirname "$f")"
313
+ command -v python3 >/dev/null 2>&1 || { echo "⚠ sin python3: no se pudo anotar el desfase de grafo (local v${local_v:-?}, hub v${server_v:-?})" >&2; return 1; }
314
+ python3 - "$f" "$local_v" "$server_v" <<'PY' 2>/dev/null || { echo "⚠ no se pudo anotar el desfase de grafo en $f (local v${local_v:-?}, hub v${server_v:-?}): no será visible en \`status\`" >&2; return 1; }
315
+ import json,sys,os,tempfile,datetime
316
+ path,lv,sv=sys.argv[1],sys.argv[2],sys.argv[3]
317
+ def num(x):
318
+ try: return int(x)
319
+ except Exception: return None
320
+ d={"local":num(lv),"server":num(sv),
321
+ "seen_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
322
+ dirn=os.path.dirname(path) or "."
323
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".graph-status.",suffix=".tmp")
324
+ try:
325
+ with os.fdopen(fd,"w") as out:
326
+ json.dump(d,out,indent=2); out.flush(); os.fsync(out.fileno())
327
+ os.replace(tmp,path)
328
+ except Exception:
329
+ try: os.unlink(tmp)
330
+ except OSError: pass
331
+ raise
332
+ PY
333
+ return 0
334
+ }
335
+
336
+ runtime_graph_status_read() {
337
+ local f; f="$(runtime_graph_status_path)"
338
+ [ -f "$f" ] || { echo "{}"; return; }
339
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
340
+ python3 - "$f" <<'PY' 2>/dev/null || echo "{}"
341
+ import json,sys
342
+ try:
343
+ print(json.dumps(json.load(open(sys.argv[1]))))
344
+ except Exception:
345
+ print("{}")
346
+ PY
347
+ }
348
+
349
+ # runtime_graph_clear_stale — el desfase dejó de existir (la proyección alcanzó al servidor).
350
+ runtime_graph_clear_stale() { rm -f "$(runtime_graph_status_path)" 2>/dev/null; return 0; }
351
+
260
352
  RUNTIME_OUTBOX_MAX_BYTES=5242880
261
353
  RUNTIME_OUTBOX_MAX_AGE_S=259200
262
354
  # [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
@@ -275,7 +367,10 @@ RUNTIME_OUTBOX_MAX_AGE_S=259200
275
367
  # [#52] `phase_advanced` es protegido: el orden de fases del hub es ESTRICTO — evictar un
276
368
  # eslabón de la cadena haría ilegales todos los eventos posteriores del slice (incluido el
277
369
  # slice_archived, que exige phase == pr).
278
- RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated phase_advanced checkpoint_created verdict_reported escalation_raised"
370
+ # [#62] `epic_proposed` viaja por el CARRIL DIRECTO (su propio endpoint, no el lote de
371
+ # /events) pero comparte cola y cota: evictar una propuesta de épica perdería trabajo humano
372
+ # ya redactado y aprobado en la terminal, sin rastro en el hub.
373
+ RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated phase_advanced checkpoint_created verdict_reported escalation_raised epic_proposed"
279
374
 
280
375
  runtime_outbox_dir() {
281
376
  echo "$(config_root)/.claude/state/outbox"
@@ -313,6 +408,167 @@ PY
313
408
  runtime_outbox_enforce_cap >/dev/null
314
409
  }
315
410
 
411
+ # runtime_enqueue_direct <type> <method> <path> <payload_file> [stamp_graph_version] — encola
412
+ # una petición del CARRIL DIRECTO [#62]: un hecho que NO viaja en el lote de `POST /events`
413
+ # sino a su propio endpoint (hoy: la propuesta de épica, hub#113). Comparte con la cola de
414
+ # eventos el directorio, el formato en disco, la cota y el sentinela de flush; lo único
415
+ # distinto es a dónde se despacha. Imprime el `client_event_id` (referencia local mientras el
416
+ # hub no ha asignado el suyo). El payload va por FICHERO, nunca por argv.
417
+ # La RUTA llega ya resuelta: runtime-client.sh no conoce la tabla de endpoints — la escribe
418
+ # quien sí la conoce (lib/runtime-ops.sh), que es el único sitio del cliente con rutas.
419
+ # [#63] Con `1` en el 5º argumento, el fichero se marca para que el DESPACHO le estampe la
420
+ # versión de grafo vigente. Sellarla al encolar sería un error: una propuesta que espera dos
421
+ # días en la cola offline saldría con la versión de hace dos días y nacería condenada al 409.
422
+ runtime_enqueue_direct() {
423
+ local type="$1" method="$2" path="$3" pfile="$4" stamp="${5:-0}" dir
424
+ dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
425
+ command -v python3 >/dev/null 2>&1 || return 1
426
+ python3 - "$dir" "$type" "$method" "$path" "$pfile" "$stamp" <<'PY' 2>/dev/null || return 1
427
+ import json,sys,os,tempfile,uuid,datetime
428
+ dir_,typ,method,path,pfile,stamp=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4],sys.argv[5],sys.argv[6]
429
+ try:
430
+ payload=json.load(open(pfile))
431
+ except Exception:
432
+ payload={}
433
+ cid=str(uuid.uuid4())
434
+ ts=datetime.datetime.now(datetime.timezone.utc)
435
+ d={"client_event_id":cid,"type":typ,"channel":"direct","method":method,"path":path,
436
+ "payload":payload,"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
437
+ if stamp=="1":
438
+ d["stamp_graph_version"]=True
439
+ fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
440
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
441
+ try:
442
+ with os.fdopen(fd,"w") as out:
443
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
444
+ os.replace(tmp,fname)
445
+ except Exception:
446
+ try: os.unlink(tmp)
447
+ except OSError: pass
448
+ raise
449
+ print(cid)
450
+ PY
451
+ runtime_outbox_enforce_cap >/dev/null
452
+ }
453
+
454
+ # _runtime_direct_field <fichero> <clave> — escalar de primer nivel de una petición encolada
455
+ # ("" si falta o el fichero es ilegible).
456
+ _runtime_direct_field() {
457
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
458
+ python3 - "$1" "$2" <<'PY' 2>/dev/null
459
+ import json,sys
460
+ try:
461
+ v=json.load(open(sys.argv[1])).get(sys.argv[2])
462
+ except Exception:
463
+ v=None
464
+ print("" if v is None else v)
465
+ PY
466
+ }
467
+
468
+ # _runtime_direct_payload <fichero> — imprime el cuerpo que viaja al endpoint del carril
469
+ # directo: el payload guardado MÁS el `client_event_id` como CLAVE DE IDEMPOTENCIA ("{}" si
470
+ # falla). [#62 · ronda final] Sin esa clave el hub no podía deduplicar el carril directo: el
471
+ # lote de `/events` la lleva desde siempre (protocolo §5), pero aquí el `client_event_id`
472
+ # vivía solo como clave de primer nivel del FICHERO y no salía en la petición. Con dos
473
+ # despachadores posibles (el daemon y el proceso de la skill), un doble envío de la misma
474
+ # propuesta creaba DOS propuestas y, al aprobarlas, dos `EP-XXX` — la colisión que motiva #62,
475
+ # reintroducida por el otro extremo. Se estampa al armar el cuerpo, no al encolar: el formato
476
+ # en disco no cambia y los ficheros ya encolados drenan sin migración.
477
+ _runtime_direct_payload() {
478
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
479
+ python3 - "$1" <<'PY' 2>/dev/null || echo "{}"
480
+ import json,sys
481
+ try:
482
+ d=json.load(open(sys.argv[1]))
483
+ p=d.get("payload")
484
+ if p is None:
485
+ p={}
486
+ if isinstance(p,dict):
487
+ cid=d.get("client_event_id")
488
+ # Un `client_event_id` ya presente en el payload manda: lo puso quien encoló y
489
+ # sobrescribirlo cambiaría la identidad de la petición a espaldas del productor.
490
+ if cid and "client_event_id" not in p:
491
+ p["client_event_id"]=cid
492
+ print(json.dumps(p,ensure_ascii=False))
493
+ except Exception:
494
+ print("{}")
495
+ PY
496
+ }
497
+
498
+ # _runtime_reject_file <fichero> <razón> — aparta una petición a rejected/ con su razón
499
+ # PERSISTIDA (la muestran `slice-ops.sh status` y `trycore-build status/doctor`: el stderr de
500
+ # un daemon no lo lee nadie). Nunca lanza.
501
+ _runtime_reject_file() {
502
+ local f="$1" reason="$2" rdir
503
+ rdir="$(runtime_outbox_dir)/rejected"
504
+ mkdir -p "$rdir"
505
+ echo "⚠ $reason" >&2
506
+ command -v python3 >/dev/null 2>&1 || { mv "$f" "$rdir/" 2>/dev/null; return 0; }
507
+ # [#62] Escritura ATÓMICA del apartado: `tempfile.mkstemp` en el mismo directorio destino
508
+ # + `os.replace` (restricción dura de la spec). Un `json.dump` directo a `dest` deja un
509
+ # fichero truncado legible a medio escribir si el proceso muere entre el open y el flush —
510
+ # inaceptable para algo que `slice-ops.sh status`/`trycore-build doctor` leen en caliente.
511
+ python3 - "$f" "$rdir" "$reason" <<'PY' 2>/dev/null || mv "$f" "$rdir/" 2>/dev/null
512
+ import json,sys,os,tempfile
513
+ f,rdir,reason=sys.argv[1],sys.argv[2],sys.argv[3]
514
+ try:
515
+ d=json.load(open(f))
516
+ except Exception:
517
+ d=None
518
+ dest=os.path.join(rdir,os.path.basename(f))
519
+ if isinstance(d,dict):
520
+ d["rejected_reason"]=reason
521
+ fd,tmp=tempfile.mkstemp(dir=rdir,prefix=".rejected.",suffix=".tmp")
522
+ try:
523
+ with os.fdopen(fd,"w") as out:
524
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
525
+ os.replace(tmp,dest)
526
+ os.unlink(f)
527
+ except Exception:
528
+ try: os.unlink(tmp)
529
+ except OSError: pass
530
+ raise
531
+ else:
532
+ os.replace(f,dest)
533
+ PY
534
+ return 0
535
+ }
536
+
537
+ # _runtime_ack_direct <fichero> <respuesta-cruda> — deja el acuse en acks/<client_event_id>.json.
538
+ # El ledger de dominio (qué propuesta es, en qué estado está) NO vive aquí: runtime-client.sh
539
+ # es agnóstico al dominio. Quien correlaciona es lib/runtime-ops.sh.
540
+ _runtime_ack_direct() {
541
+ local f="$1" resp="$2" adir
542
+ adir="$(runtime_outbox_dir)/acks"
543
+ mkdir -p "$adir"
544
+ command -v python3 >/dev/null 2>&1 || return 0
545
+ python3 - "$f" "$adir" "$resp" <<'PY' 2>/dev/null
546
+ import json,sys,os,tempfile,datetime
547
+ f,adir,resp=sys.argv[1],sys.argv[2],sys.argv[3]
548
+ try:
549
+ d=json.load(open(f))
550
+ except Exception:
551
+ d={}
552
+ try:
553
+ body=json.loads(resp)
554
+ except Exception:
555
+ body={}
556
+ cid=d.get("client_event_id") or "sin-id"
557
+ out={"client_event_id":cid,"type":d.get("type"),"status":"acked","response":body,
558
+ "acked_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
559
+ path=os.path.join(adir,cid+".json")
560
+ fd,tmp=tempfile.mkstemp(dir=adir,prefix=".ack.",suffix=".tmp")
561
+ try:
562
+ with os.fdopen(fd,"w") as fh:
563
+ json.dump(out,fh,indent=2,ensure_ascii=False); fh.flush(); os.fsync(fh.fileno())
564
+ os.replace(tmp,path)
565
+ except Exception:
566
+ try: os.unlink(tmp)
567
+ except OSError: pass
568
+ PY
569
+ return 0
570
+ }
571
+
316
572
  # runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
317
573
  # (más viejos primero); jamás descarta un tipo de RUNTIME_OUTBOX_PROTECTED. Si hubo
318
574
  # descartes, encola un telemetry_gap con el payload EXACTO del catálogo v2 [#44]
@@ -421,10 +677,245 @@ PY
421
677
 
422
678
  RUNTIME_OUTBOX_BACKOFF_SCHEDULE="1 5 30 300"
423
679
 
424
- # runtime_dispatch_outboxdespacho best-effort de la cola. No-op en modo legacy.
680
+ # _runtime_partition_outbox <fichero…> imprime "direct <ruta>", "batch <ruta>" o
681
+ # "bad <ruta>" por línea. Una sola invocación de python3 para todos los ficheros: el despacho
682
+ # corre en el hilo del daemon cada pocos segundos y no puede pagar un proceso por fichero.
683
+ # [#62 · ronda final] La tercera clase existe porque un fichero ILEGIBLE (JSON corrupto, o un
684
+ # JSON que no es un objeto) no se puede afirmar que sea del lote: clasificarlo como `batch`
685
+ # lo condenaba a saltarse en silencio al armar el cuerpo y a quedarse atascado PARA SIEMPRE
686
+ # —una pérdida muda sobre un tipo que además está protegido de la evicción por cota—. El
687
+ # llamador lo aparta a rejected/ nombrando la causa.
688
+ _runtime_partition_outbox() {
689
+ command -v python3 >/dev/null 2>&1 || return 1
690
+ python3 - "$@" <<'PY' 2>/dev/null
691
+ import json,sys
692
+ for p in sys.argv[1:]:
693
+ try:
694
+ d=json.load(open(p))
695
+ except Exception:
696
+ d=None
697
+ if not isinstance(d,dict):
698
+ print("bad "+p)
699
+ continue
700
+ print(("direct " if d.get("channel")=="direct" else "batch ")+p)
701
+ PY
702
+ }
703
+
704
+ # _runtime_bump_field <fichero> <clave> <valor-entero> — persiste un contador dentro de la
705
+ # petición encolada (escritura atómica, con `fsync` — mismo patrón que el resto del fichero).
706
+ # Sin esto, el conteo de 409 viviría en memoria y se perdería entre ciclos del daemon: el
707
+ # reintento sería infinito por construcción. [Ronda de arreglo 1, Important 2] El fallo YA NO
708
+ # se traga: se propaga (rc distinguible) para que el llamador aparte el fichero en vez de
709
+ # reintentar para siempre sin poder nunca completar el conteo.
710
+ _runtime_bump_field() {
711
+ command -v python3 >/dev/null 2>&1 || return 1
712
+ python3 - "$1" "$2" "$3" <<'PY' 2>/dev/null
713
+ import json,sys,os,tempfile
714
+ path,key,val=sys.argv[1],sys.argv[2],sys.argv[3]
715
+ try:
716
+ d=json.load(open(path))
717
+ except Exception:
718
+ raise SystemExit(1) # fichero ilegible: rc distinguible, el llamador decide (no reintento ciego)
719
+ d[key]=int(val)
720
+ dirn=os.path.dirname(path) or "."
721
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".outbox.",suffix=".tmp")
722
+ try:
723
+ with os.fdopen(fd,"w") as out:
724
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
725
+ os.replace(tmp,path)
726
+ except Exception:
727
+ try: os.unlink(tmp)
728
+ except OSError: pass
729
+ raise
730
+ PY
731
+ }
732
+
733
+ # _runtime_mark_graph_409 <fichero> <n> <version-local> — persiste el conteo de rechazos por
734
+ # grafo rancio Y la versión local con la que se produjeron. Los dos campos van juntos porque la
735
+ # racha es «tres rechazos contra la MISMA versión local», no «tres rechazos a secas» (ver
736
+ # _runtime_dispatch_direct). Escritura atómica con fsync y fallo propagado (rc distinguible),
737
+ # igual que _runtime_bump_field: si el conteo no se puede persistir, el llamador aparta el
738
+ # fichero en vez de reintentar sin fin.
739
+ _runtime_mark_graph_409() {
740
+ command -v python3 >/dev/null 2>&1 || return 1
741
+ python3 - "$1" "$2" "$3" <<'PY' 2>/dev/null
742
+ import json,sys,os,tempfile
743
+ path,n,gv=sys.argv[1],sys.argv[2],sys.argv[3]
744
+ try:
745
+ d=json.load(open(path))
746
+ except Exception:
747
+ raise SystemExit(1) # fichero ilegible: rc distinguible, el llamador decide
748
+ d["graph_409"]=int(n)
749
+ # Ausencia de versión = None, nunca 0: «salió sin estampar» y «salió con la versión 0» son
750
+ # cosas distintas, y confundirlas haría que la racha se reiniciara sola sin motivo.
751
+ d["graph_409_at_version"]=gv if gv else None
752
+ dirn=os.path.dirname(path) or "."
753
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".outbox.",suffix=".tmp")
754
+ try:
755
+ with os.fdopen(fd,"w") as out:
756
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
757
+ os.replace(tmp,path)
758
+ except Exception:
759
+ try: os.unlink(tmp)
760
+ except OSError: pass
761
+ raise
762
+ PY
763
+ }
764
+
765
+ # _runtime_dispatch_direct <fichero…> — despacha el carril directo UNO a uno (cada petición
766
+ # tiene su endpoint; no hay lote que armar). Contrato de resultado por fichero:
767
+ # 2xx → ack en acks/, se borra el fichero y se limpia el desfase de grafo
768
+ # si había uno anotado (la petición llegó, luego ya no vamos rancios).
769
+ # 404 → el hub NO implementa esa ruta (instancia sin soporte, hub#113
770
+ # pendiente): se aparta diciéndolo con esas palabras.
771
+ # 409 reason=stale_graph → grafo rancio [#63]: se anota el desfase y se CONSERVA (no es un
772
+ # rechazo de contrato); a los 3 seguidos se aparta. Cualquier OTRO
773
+ # 409 (propuesta duplicada, conflicto de estado…) sigue el camino
774
+ # normal de rechazo de contrato — el `reason` es lo único que decide.
775
+ # 4xx (salvo 404/408/409-stale_graph/429) → rechazo de contrato: se aparta a rejected/.
776
+ # 000 · 5xx · 408 · 429 → transitorio: se CONSERVA y se reintenta en el próximo ciclo.
777
+ # rc 1 si algo quedó pendiente por causa transitoria, 0 si no queda nada del carril.
778
+ # No agenda backoff propio a propósito: el carril directo es de volumen bajísimo (una
779
+ # propuesta de épica) y compartir el backoff del lote dejaría los hechos de dominio del slice
780
+ # esperando detrás de una propuesta. El ritmo real de reintento lo marca el ciclo del daemon.
781
+ _runtime_dispatch_direct() {
782
+ local f rc=0 resp status type path method payload gv server_gv n409 prev_gv stamped reason
783
+ for f in "$@"; do
784
+ [ -f "$f" ] || continue
785
+ # [Ronda final] `gv` se reinicia POR ITERACIÓN: es `local` a la función y solo se asigna
786
+ # dentro del `if stamp_graph_version`, así que un fichero sin estampar que recibiera un 409
787
+ # heredaba la versión del fichero anterior y la usaba en el mensaje, en el marcador de
788
+ # desfase y en la racha — un dato inventado con apariencia de medida.
789
+ gv=""
790
+ type="$(_runtime_direct_field "$f" type)"
791
+ method="$(_runtime_direct_field "$f" method)"
792
+ path="$(_runtime_direct_field "$f" path)"
793
+ [ -n "$method" ] || method=POST
794
+ if [ -z "$path" ]; then
795
+ _runtime_reject_file "$f" "petición directa sin ruta de despacho (fichero corrupto): no se envió"
796
+ continue
797
+ fi
798
+ payload="$(_runtime_direct_payload "$f")"
799
+ # [#63] Estampado AL DESPACHAR, con la versión que la proyección tenga en ESTE instante.
800
+ if [ "$(_runtime_direct_field "$f" stamp_graph_version)" = "True" ]; then
801
+ gv="$(_runtime_graph_version)"
802
+ if [ -n "$gv" ]; then
803
+ # [Ronda de arreglo 1, Important 1] Si `$gv` no es un entero válido (hub#115 aún no
804
+ # existe: nada coacciona el tipo en `agent-context.sh`), `int()` lanza y python no
805
+ # imprime nada — NUNCA se sobrescribe `payload` con esa cadena vacía, o la petición
806
+ # saldría sin cuerpo y el 4xx del hub se reportaría como "rechazo del hub" en vez de
807
+ # como el fallo de estampado que de verdad es.
808
+ stamped="$(printf '%s' "$payload" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["graph_version"]=int(sys.argv[1]); print(json.dumps(d,ensure_ascii=False))' "$gv" 2>/dev/null)"
809
+ [ -n "$stamped" ] && payload="$stamped"
810
+ fi
811
+ fi
812
+ resp="$(_runtime_http "$method" "$path" "$payload")"
813
+ status="$(runtime_http_status)"
814
+ case "$status" in
815
+ 200|201|202)
816
+ _runtime_ack_direct "$f" "$resp"
817
+ rm -f "$f"
818
+ runtime_graph_clear_stale ;;
819
+ 404)
820
+ # [bash 3.2] un carácter multibyte pegado a `$var` sin llaves («$type») rompe el
821
+ # escaneo del nombre bajo `set -u` en esta bash — `${type}` lo aísla.
822
+ _runtime_reject_file "$f" "el hub no expone $path (HTTP 404): esta instancia está sin soporte para «${type}» — actualiza el hub y vuelve a proponer" ;;
823
+ 409)
824
+ # [Ronda de arreglo 1, Important 4] El `reason` decide, igual que en `_claim_once`:
825
+ # un 409 por OTRA causa (propuesta duplicada, conflicto de estado…) no es un grafo
826
+ # rancio y tratarlo como tal apartaría la propuesta con un diagnóstico falso.
827
+ reason="$(printf '%s' "$resp" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("reason") or "")' 2>/dev/null)"
828
+ if [ "$reason" = "stale_graph" ]; then
829
+ # No es un error de contrato: el grafo del hub cambió. Se anota el desfase (lo
830
+ # muestra `status`) y la petición se CONSERVA — el refresco del heartbeat (#61)
831
+ # traerá la versión nueva y el próximo ciclo la re-estampa. Con 3 rechazos seguidos
832
+ # se deja de insistir: a esas alturas no es una carrera, y un bucle silencioso es
833
+ # peor que un rechazo visible.
834
+ server_gv="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("graph_version") or "")' 2>/dev/null)"
835
+ runtime_graph_note_stale "$gv" "$server_gv"
836
+ n409="$(_runtime_direct_field "$f" graph_409)"
837
+ case "$n409" in ''|*[!0-9]*) n409=0 ;; esac
838
+ # [Ronda final] La racha cuenta rechazos contra la MISMA versión local estampada. Las
839
+ # dos cadencias son independientes y nada las acopla: el despacho corre en cada tick
840
+ # que encuentre `outbox/.flush-request` —y `session-stop.sh` lo deja en CADA turno—,
841
+ # mientras el refresco de contexto va a `runtime.context_refresh_s` (45 s por defecto,
842
+ # y `0` lo DESACTIVA, valor soportado). Contando rechazos a secas, tres turnos en
843
+ # menos de 45 s gastaban las tres vidas de la propuesta contra la misma foto del
844
+ # grafo y la apartaban pidiendo un refresco que el cliente nunca se permitió hacer.
845
+ # Si la versión local cambió, hubo refresco de verdad: la racha empieza de cero.
846
+ prev_gv="$(_runtime_direct_field "$f" graph_409_at_version)"
847
+ [ "$prev_gv" = "$gv" ] || n409=0
848
+ n409=$((n409 + 1))
849
+ if [ "$n409" -ge 3 ]; then
850
+ _runtime_reject_file "$f" "el hub rechazó «${type}» tres veces por el MISMO grafo obsoleto (local v${gv:-?}, hub v${server_gv:-?}): refresca el proyecto y vuelve a proponer"
851
+ elif _runtime_mark_graph_409 "$f" "$n409" "$gv"; then
852
+ rc=1
853
+ else
854
+ # [Ronda de arreglo 1, Important 2] El conteo no se pudo persistir: reintentar a
855
+ # ciegas sería un bucle infinito (el contador jamás llegaría a 3). Se aparta.
856
+ _runtime_reject_file "$f" "no se pudo persistir el conteo de rechazos por grafo obsoleto de «${type}»: se aparta para evitar un reintento sin fin"
857
+ fi
858
+ else
859
+ _runtime_reject_file "$f" "el hub rechazó «${type}» (HTTP $status): $resp — no se reintentará"
860
+ fi ;;
861
+ 408|429|5*|000)
862
+ # [Ronda de arreglo 1, Minor 6] «3 rechazos CONSECUTIVOS» debe ser literal: un
863
+ # transitorio intercalado (timeout, 5xx…) no es un stale_graph y no debe sumar a esa
864
+ # racha — se reinicia el contador si había uno. Best-effort: si la escritura falla,
865
+ # el `rc=1` de abajo igual reintenta el próximo ciclo (no hay riesgo de bucle, ya que
866
+ # solo la rama `409 stale_graph` puede volver a incrementarlo).
867
+ n409="$(_runtime_direct_field "$f" graph_409)"
868
+ case "$n409" in ''|*[!0-9]*) n409=0 ;; esac
869
+ [ "$n409" -gt 0 ] && _runtime_bump_field "$f" graph_409 0
870
+ rc=1 ;;
871
+ 4*)
872
+ _runtime_reject_file "$f" "el hub rechazó «${type}» (HTTP $status): $resp — no se reintentará" ;;
873
+ *)
874
+ rc=1 ;;
875
+ esac
876
+ done
877
+ return $rc
878
+ }
879
+
880
+ RUNTIME_DISPATCH_LOCK_MAX_AGE_MIN=2
881
+
882
+ runtime_dispatch_lock_path() { echo "$(config_root)/.claude/state/.outbox-dispatch.lock"; }
883
+
884
+ # runtime_dispatch_outbox — envoltura con MUTEX de `_runtime_dispatch_outbox_once`.
885
+ #
886
+ # [#62 · ronda final] Hasta esta rama el único despachador era el daemon (proceso único) y no
887
+ # hacía falta serializar nada. `slice-ops.sh propose-epic` despacha ahora en PRIMER PLANO, así
888
+ # que el proceso de la skill y el daemon pueden recorrer la MISMA cola a la vez. Para el lote
889
+ # no sería grave (su seguridad viene de la idempotencia server-side por `client_event_id`),
890
+ # pero `runtime_http_status` es un FICHERO compartido: entre el `_runtime_http` de un proceso y
891
+ # su lectura del status puede colarse el curl del otro, y un `409` leído como `2xx` borraría la
892
+ # petición dándola por entregada sin que el hub la haya visto jamás.
893
+ #
894
+ # `mkdir` es atómico (mismo patrón que `hb_spawnlock` en heartbeat.sh); un lock huérfano
895
+ # —proceso muerto entre el mkdir y el rmdir— se libera por edad. El umbral (2 min) queda muy
896
+ # por encima de un ciclo real: el lote es una petición y el carril directo es de volumen
897
+ # bajísimo, ambos con `curl -m 10`.
898
+ runtime_dispatch_outbox() {
899
+ local lock rc
900
+ [ "$(runtime_mode)" = "legacy" ] && return 0
901
+ lock="$(runtime_dispatch_lock_path)"
902
+ mkdir -p "$(dirname "$lock")"
903
+ if [ -d "$lock" ] && [ -n "$(find "$lock" -maxdepth 0 -mmin "+$RUNTIME_DISPATCH_LOCK_MAX_AGE_MIN" 2>/dev/null)" ]; then
904
+ rmdir "$lock" 2>/dev/null
905
+ fi
906
+ # Otro proceso está despachando ESTA cola: no se toca nada y se reporta que quedó trabajo
907
+ # (rc 1). Devolver 0 sería afirmar que la cola quedó drenada sin haberlo comprobado.
908
+ mkdir "$lock" 2>/dev/null || return 1
909
+ _runtime_dispatch_outbox_once
910
+ rc=$?
911
+ rmdir "$lock" 2>/dev/null
912
+ return $rc
913
+ }
914
+
915
+ # _runtime_dispatch_outbox_once — despacho best-effort de la cola. No-op en modo legacy.
425
916
  # Respeta un backoff persistido (nunca bloquea reintentando en el hilo del hook). FIFO
426
917
  # global (el agrupado por-agregado nace con el catálogo de eventos del sub-slice C).
427
- runtime_dispatch_outbox() {
918
+ _runtime_dispatch_outbox_once() {
428
919
  local mode; mode="$(runtime_mode)"
429
920
  [ "$mode" = "legacy" ] && return 0
430
921
 
@@ -442,9 +933,42 @@ runtime_dispatch_outbox() {
442
933
  fi
443
934
  [ "$now" -lt "$next_ok" ] && return 0
444
935
 
445
- local files=()
446
- while IFS= read -r f; do files+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
447
- [ ${#files[@]} -eq 0 ] && return 0
936
+ # [#62] Dos carriles, un solo punto de despacho: los ficheros con `channel: "direct"` van a
937
+ # su propio endpoint; el resto arma el lote de POST /events como siempre.
938
+ local all=() files=() direct=() bad=() f line direct_rc=0 part prc
939
+ while IFS= read -r f; do all+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
940
+ [ ${#all[@]} -eq 0 ] && return 0
941
+ # [Ronda final] El rc de la partición se COMPRUEBA. Si falla (E2BIG con una cola muy grande,
942
+ # python3 caído a mitad), `direct` y `files` quedaban vacíos, se borraba el backoff agendado
943
+ # y la función devolvía 0 con la cola llena: un fallo reportado como drenaje limpio. Ahora se
944
+ # dice, no se toca el estado de backoff y se reintenta en el próximo ciclo.
945
+ part="$(_runtime_partition_outbox "${all[@]}")"
946
+ prc=$?
947
+ if [ "$prc" -ne 0 ]; then
948
+ echo "⚠ no se pudo clasificar la cola de la outbox (${#all[@]} fichero(s)): no se despacha en este ciclo, se reintentará" >&2
949
+ return 1
950
+ fi
951
+ while IFS= read -r line; do
952
+ case "$line" in
953
+ "direct "*) direct+=("${line#direct }") ;;
954
+ "batch "*) files+=("${line#batch }") ;;
955
+ "bad "*) bad+=("${line#bad }") ;;
956
+ esac
957
+ done <<EOF
958
+ $part
959
+ EOF
960
+ if [ ${#bad[@]} -gt 0 ]; then
961
+ for f in "${bad[@]}"; do
962
+ _runtime_reject_file "$f" "fichero de la outbox ilegible (no es un objeto JSON): no se puede saber por qué carril iba ni qué contiene — se aparta en vez de dejarlo atascado en la cola para siempre"
963
+ done
964
+ fi
965
+ if [ ${#direct[@]} -gt 0 ]; then
966
+ _runtime_dispatch_direct "${direct[@]}" || direct_rc=1
967
+ fi
968
+ if [ ${#files[@]} -eq 0 ]; then
969
+ [ "$direct_rc" -eq 0 ] && rm -f "$state_file"
970
+ return "$direct_rc"
971
+ fi
448
972
 
449
973
  # [#37] El hub (`EventBatchIn`) exige el sobre {"events": [...]} — el array pelado era
450
974
  # 422 en cada intento y la cola no drenaba jamás. Cada elemento va con `event_type`
@@ -544,9 +1068,13 @@ PY
544
1068
  # Si todo lo pendiente eran tipos sin equivalente (ya apartados), no hay nada que enviar.
545
1069
  local n_items
546
1070
  n_items="$(printf '%s' "$batch" | python3 -c 'import json,sys;print(len(json.load(sys.stdin).get("events") or []))' 2>/dev/null || echo 0)"
1071
+ # [#62] El lote no tenía nada que enviar, pero el carril directo puede seguir con algo
1072
+ # pendiente por causa transitoria: el rc de la función tiene que reflejarlo (no solo el
1073
+ # rc del lote), porque quien lo consume (p. ej. slice-ops.sh) decide en base a ÉL si
1074
+ # queda trabajo por drenar.
547
1075
  if [ "$n_items" = "0" ]; then
548
1076
  rm -f "$state_file"
549
- return 0
1077
+ return "$direct_rc"
550
1078
  fi
551
1079
 
552
1080
  local resp status
@@ -603,8 +1131,11 @@ for p in paths:
603
1131
  except OSError:
604
1132
  pass
605
1133
  PY
1134
+ # [#62] Igual que arriba: el lote se resolvió (200), pero el rc global tiene que
1135
+ # reflejar también el carril directo si algo suyo quedó pendiente por causa
1136
+ # transitoria en este mismo ciclo.
606
1137
  rm -f "$state_file"
607
- return 0
1138
+ return "$direct_rc"
608
1139
  fi
609
1140
 
610
1141
  # [#37] Un 4xx del LOTE (salvo 408/429) es un rechazo del contrato, no red caída:
@@ -614,7 +1145,7 @@ PY
614
1145
  case "$status" in
615
1146
  408|429) : ;;
616
1147
  4*)
617
- local rejected_dir f
1148
+ local rejected_dir
618
1149
  rejected_dir="$dir/rejected"
619
1150
  mkdir -p "$rejected_dir"
620
1151
  echo "⛔ el hub rechazó el lote de eventos (HTTP $status): $resp" >&2