@trycore/spec-build-harness 0.13.0 → 0.14.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.
@@ -4,9 +4,10 @@
4
4
  # `building-a-slice`, los comandos /build:* y los agentes, UNA invocación por transición.
5
5
  #
6
6
  # uso: slice-ops.sh <subcomando> [opciones]
7
- # subcomandos IMPLEMENTADOS (13 del plan + phase, issue #52): mode | claim | next-step |
8
- # phase | gate | submit | archive | wiring | progress | checkpoint | fact |
9
- # propose-asset | status | escalate.
7
+ # subcomandos IMPLEMENTADOS (13 del plan + phase, issue #52; + propose-epic/epic-status/
8
+ # epic-writeback, issue #62): mode | claim | next-step | phase | gate | submit |
9
+ # archive | wiring | progress | checkpoint | fact | propose-asset |
10
+ # propose-epic | epic-status | epic-writeback | status | escalate.
10
11
  #
11
12
  # Contrato con la prosa: cada subcomando devuelve un código de salida tipado
12
13
  # (0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo) y
@@ -38,6 +39,13 @@ uso: slice-ops.sh <subcomando> [opciones]
38
39
  fact <scaffold-confirmed|design-source|project-kind|foundation|harness-phase> …
39
40
  reporta un hecho de proyecto confirmado
40
41
  propose-asset --type-key K --path P --content-file F
42
+ propose-epic --file borrador.json propone una épica al hub (SIN EP-XXX: la
43
+ identidad la asigna el hub al aprobar)
44
+ epic-status [--id P] estado de las propuestas (QUEUED|PROPOSED|
45
+ APPROVED|REJECTED|FAILED)
46
+ epic-writeback [--id P] [--file F] escribe en epicas.md las épicas ya APROBADAS,
47
+ con el código que asignó el hub (F debe estar
48
+ bajo docs/03-backlog/: carve-out §9.2)
41
49
  status [--no-refresh] informe local del agente
42
50
  escalate «razón» | --file F [--gate G] registra un bloqueo (la decisión es humana)
43
51
 
@@ -97,9 +105,16 @@ PY
97
105
  # [#39] El cuerpo lleva SOLO context_hashes: `TasksNextIn` del hub no declara epic_code
98
106
  # (pydantic lo ignoraba en silencio y la cola repartía OTRA épica con claim "exitoso").
99
107
  _claim_once() {
100
- local hashes body resp status reason
108
+ local hashes body resp status reason gv
101
109
  hashes="$(ops_context_hashes)"
102
- body="{\"context_hashes\":$hashes}"
110
+ # [#63] La versión de grafo viaja SOLO si la conocemos: un hub que no versiona (instancia
111
+ # antigua) debe seguir viendo el mismo cuerpo de siempre — compatibilidad hacia atrás.
112
+ gv="$(_runtime_graph_version)"
113
+ if [ -n "$gv" ]; then
114
+ body="{\"context_hashes\":$hashes,\"graph_version\":$gv}"
115
+ else
116
+ body="{\"context_hashes\":$hashes}"
117
+ fi
103
118
  resp="$(runtime_post "$OPS_PATH_CLAIM" "$body")"
104
119
  status="$(runtime_http_status)"
105
120
  case "$status" in
@@ -115,6 +130,15 @@ _claim_once() {
115
130
  reason="$(_claim_field "$resp" reason)"
116
131
  [ -n "$reason" ] || reason="$(_claim_field "$resp" error)"
117
132
  case "$reason" in
133
+ stale_graph)
134
+ # [#63] No es un error: es la señal de que operamos contra una foto vieja del
135
+ # backlog. Se anota el desfase (lo muestra `status`) y se pide UN reintento con la
136
+ # proyección refrescada. Mismo patrón que el 409 del lease en hb_renew.
137
+ local server_gv
138
+ server_gv="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("graph_version") or "")' 2>/dev/null)"
139
+ runtime_graph_note_stale "$gv" "$server_gv"
140
+ echo "⚠ 409 (stale_graph): el grafo local (v${gv:-?}) va por detrás del hub (v${server_gv:-?})" >&2
141
+ return 21 ;;
118
142
  drift|contention)
119
143
  echo "⚠ 409 ($reason): reintentable" >&2
120
144
  return 20 ;;
@@ -185,24 +209,36 @@ cmd_claim() {
185
209
  bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
186
210
  _claim_once
187
211
  rc=$?
188
- if [ $rc -eq 20 ]; then
189
- # Drift o contención: reintentar EXACTAMENTE una vez (protocolo §7). Pasar "" a
190
- # context-sync.sh NO fuerza nada: su propio fallback (`EXPECTED="$(runtime_field
191
- # manifest_hash)"`) recae en la caché de credenciales, que en el escenario de drift
192
- # YA coincide con el lock local (eso es precisamente lo que distingue "drift" del
193
- # estado real del servidor) el primer guard de context-sync.sh corta en seco y
194
- # nunca llega a preguntarle nada al servidor. Se refresca antes el estado REAL
195
- # (GET /agent/context, que sí toca la red) y se usa SU manifest_hash como EXPECTED:
196
- # si el drift es real, difiere del lock y el guard deja pasar el GET del manifiesto.
212
+ if [ $rc -eq 20 ] || [ $rc -eq 21 ]; then
213
+ # Drift, contención o grafo rancio: refrescar el estado REAL y reintentar EXACTAMENTE una
214
+ # vez (protocolo §7). Pasar "" a context-sync.sh no fuerza nada su fallback recae en la
215
+ # caché de credenciales, que en el escenario de drift YA coincide con el lock local —, así
216
+ # que se refresca antes con GET /agent/context y se usa SU manifest_hash como EXPECTED.
217
+ # Para `stale_graph` ese mismo refresco es justamente lo que trae la versión nueva.
197
218
  agent_context_fetch_and_cache >/dev/null 2>&1
198
219
  expected="$(projection_get context.manifest_hash "")"
199
220
  bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
221
+ local prev_rc=$rc
200
222
  _claim_once
201
223
  rc=$?
224
+ if [ $rc -eq 21 ]; then
225
+ # Segundo 409 con el grafo ya refrescado: no es una carrera, es un desfase que el
226
+ # cliente no puede resolver solo. El mensaje NOMBRA el desfase y la acción.
227
+ local gs
228
+ gs="$(runtime_graph_status_read)"
229
+ echo "⛔ el hub rechaza el claim por versión de grafo obsoleta, incluso tras refrescar." >&2
230
+ echo " desfase: $gs" >&2
231
+ echo " El backlog del hub cambió mientras reclamabas (una épica propuesta, aprobada o" >&2
232
+ echo " rechazada). Vuelve a intentarlo en unos segundos; si persiste, revisa en la consola" >&2
233
+ echo " del hub que este proyecto no esté a mitad de un import de grafo." >&2
234
+ return $OPS_RC_REJECTED
235
+ fi
202
236
  if [ $rc -eq 20 ]; then
203
237
  echo "otro agente tomó la tarea; pide la siguiente"
204
238
  return $OPS_RC_NO_WORK
205
239
  fi
240
+ # Un claim correcto tras un stale_graph deja el marcador de desfase sin sentido.
241
+ [ $rc -eq 0 ] && [ "$prev_rc" -eq 21 ] && runtime_graph_clear_stale
206
242
  fi
207
243
  return $rc
208
244
  }
@@ -426,6 +462,415 @@ cmd_propose_asset() {
426
462
  esac
427
463
  }
428
464
 
465
+ # ── propose-epic ─────────────────────────────────────────────────────────────────────
466
+ # [#62] La terminal PROPONE, el hub decide la identidad, un humano aprueba. El agente pierde
467
+ # a propósito la potestad de inventar el `EP-XXX`: elegirlo mirando `epicas.md` es lo que hizo
468
+ # que dos terminales crearan EP-042 y EP-043 el mismo día y sus historias colisionaran.
469
+ # Va por el carril directo de la outbox (endpoint propio, hub#113) para sobrevivir a estar
470
+ # offline, y se intenta despachar en el acto para poder devolver el identificador del hub.
471
+
472
+ # _epic_draft_normalize <fichero> — imprime el cuerpo de la propuesta ya normalizado, o "" con
473
+ # rc 2 si el borrador no vale. `epic_code`/`code`/`id` se DESCARTAN (no se rechaza la
474
+ # propuesta: la decisión de hub#113 es ignorar el campo, no fallar por él).
475
+ _epic_draft_normalize() {
476
+ local f="$1"
477
+ # [Importante 4] sin python3 no es "borrador inválido" (rc 2 de uso): es una degradación de
478
+ # infraestructura — se anuncia con esas palabras y con OFFLINE, para que no se confunda con
479
+ # un borrador mal escrito.
480
+ command -v python3 >/dev/null 2>&1 || {
481
+ echo "⛔ sin python3: no se puede validar el borrador en local" >&2
482
+ return $OPS_RC_OFFLINE
483
+ }
484
+ # OJO: sin `2>/dev/null` aquí — el llamador captura este stderr en `$errf` para mostrar
485
+ # el motivo del rc 2 (validación) o el aviso de `epic_code` ignorado; silenciarlo aquí
486
+ # los perdería a los dos.
487
+ python3 - "$f" <<'PY'
488
+ import json,sys
489
+ CAPAS={"foundational","business","technical"}
490
+ try:
491
+ d=json.load(open(sys.argv[1]))
492
+ except Exception:
493
+ print("⛔ el borrador no es un JSON legible", file=sys.stderr); raise SystemExit(2)
494
+ if not isinstance(d,dict):
495
+ print("⛔ el borrador debe ser un objeto JSON", file=sys.stderr); raise SystemExit(2)
496
+ title=(d.get("title") or "").strip()
497
+ objective=(d.get("objective") or "").strip()
498
+ if not title:
499
+ print("⛔ el borrador no trae `title` (título de la épica)", file=sys.stderr); raise SystemExit(2)
500
+ if not objective:
501
+ print("⛔ el borrador no trae `objective` (objetivo de la épica)", file=sys.stderr); raise SystemExit(2)
502
+ layer=d.get("layer") or "business"
503
+ if layer not in CAPAS:
504
+ print("⛔ `layer` debe ser uno de: %s" % ", ".join(sorted(CAPAS)), file=sys.stderr); raise SystemExit(2)
505
+ ignorados=[k for k in ("epic_code","code","id") if d.get(k)]
506
+ stories=[]
507
+ for s in d.get("stories") or []:
508
+ if isinstance(s,str):
509
+ stories.append({"title":s})
510
+ elif isinstance(s,dict) and (s.get("title") or "").strip():
511
+ st={"title":s["title"].strip()}
512
+ if s.get("acceptance_criteria"):
513
+ st["acceptance_criteria"]=str(s["acceptance_criteria"])[:2000]
514
+ stories.append(st)
515
+ out={"title":title[:300],"objective":objective[:2000],"layer":layer,
516
+ "files_scope":[str(x) for x in (d.get("files_scope") or []) if isinstance(x,(str,int))],
517
+ "depends_on":[str(x) for x in (d.get("depends_on") or []) if isinstance(x,(str,int))],
518
+ "stories":stories,"origin":"harness-draft"}
519
+ if ignorados:
520
+ print("IGNORADOS %s" % ",".join(ignorados), file=sys.stderr)
521
+ print(json.dumps(out,ensure_ascii=False))
522
+ PY
523
+ }
524
+
525
+ cmd_propose_epic() {
526
+ local file="" mode pid norm errf rc cid pfile ack proposal_id reason
527
+ while [ $# -gt 0 ]; do
528
+ case "$1" in
529
+ --file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
530
+ *) shift ;;
531
+ esac
532
+ done
533
+ if [ -z "$file" ]; then
534
+ echo "uso: slice-ops.sh propose-epic --file borrador.json" >&2
535
+ echo " borrador: {title, objective, layer, files_scope[], depends_on[], stories[]}" >&2
536
+ echo " SIN EP-XXX: el código lo asigna el hub al aprobar." >&2
537
+ return $OPS_RC_USAGE
538
+ fi
539
+ [ -f "$file" ] || { echo "⛔ no existe el borrador: $file" >&2; return $OPS_RC_USAGE; }
540
+ mode="$(ops_mode)"
541
+ # [Ronda final] El texto ramifica por modo: en `dual` SÍ hay hub (el fichero es primario,
542
+ # pero el proyecto está registrado), y decirle al usuario «no hay hub al que proponer» le
543
+ # hacía buscar un problema de conexión que no existe.
544
+ if [ "$mode" = "dual" ]; then
545
+ echo "DUAL: el fichero local es primario y el carril de propuesta solo opera en modo \`runtime\`."
546
+ echo "La épica se crea en discovery (\`/trycore:epicas\`) o, si es la caparazón, por el"
547
+ echo "carve-out de /build:onboard Fase 2c; llega al hub por el import de admin."
548
+ return $OPS_RC_LEGACY
549
+ fi
550
+ if [ "$mode" != "runtime" ]; then
551
+ echo "LEGACY: la épica se crea en discovery (\`/trycore:epicas\`) o, si es la caparazón,"
552
+ echo "por el carve-out de /build:onboard Fase 2c. No hay hub al que proponer."
553
+ return $OPS_RC_LEGACY
554
+ fi
555
+ pid="$(runtime_field project_id)"
556
+ [ -n "$pid" ] || { echo "⛔ sin project_id en runtime.credentials: corre \`trycore-build init\`" >&2; return $OPS_RC_REJECTED; }
557
+
558
+ errf="$(mktemp)" || return $OPS_RC_OFFLINE
559
+ norm="$(_epic_draft_normalize "$file" 2>"$errf")"
560
+ rc=$?
561
+ # [Importante 4] rc OFFLINE (sin python3) no es lo mismo que rc USAGE (borrador inválido):
562
+ # el primero es fallo de infraestructura, reintentable; el segundo es del agente.
563
+ if [ "$rc" -eq "$OPS_RC_OFFLINE" ]; then
564
+ cat "$errf" >&2
565
+ rm -f "$errf"
566
+ return $OPS_RC_OFFLINE
567
+ fi
568
+ if [ $rc -ne 0 ] || [ -z "$norm" ]; then
569
+ cat "$errf" >&2
570
+ rm -f "$errf"
571
+ return $OPS_RC_USAGE
572
+ fi
573
+ if grep -q '^IGNORADOS ' "$errf"; then
574
+ echo "⚠ código de épica ignorado ($(sed -n 's/^IGNORADOS //p' "$errf")): la identidad la asigna el hub al aprobar." >&2
575
+ fi
576
+ rm -f "$errf"
577
+
578
+ pfile="$(ops_tmpjson "$norm")" || return $OPS_RC_OFFLINE
579
+ # El `1` final pide el estampado de la versión de grafo AL DESPACHAR [#63]: una propuesta
580
+ # encolada offline debe salir con la versión vigente en ese momento, no con la de ayer.
581
+ cid="$(runtime_enqueue_direct epic_proposed POST "$(ops_path_epic_proposals "$pid")" "$pfile" 1)"
582
+ rm -f "$pfile"
583
+ if [ -z "$cid" ]; then
584
+ echo "⛔ no se pudo encolar la propuesta (outbox no escribible): guárdala y reintenta." >&2
585
+ return $OPS_RC_OFFLINE
586
+ fi
587
+ ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"QUEUED\",\"proposal_id\":null,\"epic_code\":null,\"written_back_at\":null,\"draft\":$norm}"
588
+
589
+ # Intento de despacho inmediato: si hay red, la skill puede enseñar YA el identificador.
590
+ runtime_dispatch_outbox >/dev/null 2>&1
591
+ ack="$(ops_epic_ack_read "$cid")"
592
+ if [ -n "$ack" ]; then
593
+ proposal_id="$(printf '%s' "$ack" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or {}).get("proposal_id") or "")' 2>/dev/null)"
594
+ ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$proposal_id")}"
595
+ echo "propuesta enviada: ${proposal_id:-sin identificador en la respuesta}"
596
+ echo "Queda en PROPOSED: NO es reclamable hasta que un humano la apruebe en la consola del hub."
597
+ echo "El código EP-XXX lo asigna el hub AL APROBAR. Consulta con \`slice-ops.sh epic-status\`."
598
+ return $OPS_RC_OK
599
+ fi
600
+ reason="$(ops_epic_rejected_reason "$cid")"
601
+ if [ -n "$reason" ]; then
602
+ ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"FAILED\",\"reason\":$(ops_json_str "$reason")}"
603
+ echo "⛔ la propuesta no se pudo entregar: $reason" >&2
604
+ return $OPS_RC_REJECTED
605
+ fi
606
+ echo "propuesta encolada: $cid"
607
+ # [Ronda final] El texto ya no afirma «sin conexión»: desde que el despacho lleva mutex
608
+ # (A1), este camino también se toma cuando el daemon estaba despachando la cola en ese
609
+ # mismo instante y el intento en primer plano cedió el turno. La propuesta está encolada y
610
+ # el daemon la lleva en los dos casos —el rc 5 y la conducta no cambian—, pero nombrar una
611
+ # causa que puede ser falsa manda al usuario a revisar una red que funciona.
612
+ echo "PENDIENTE: se despachará sola (el daemon de heartbeat la lleva). Sin conexión aún, o"
613
+ echo "el daemon estaba drenando la cola en este instante. \`slice-ops.sh epic-status\` la sigue."
614
+ echo "NO escribas la épica en docs/03-backlog/epicas.md: el código lo asigna el hub al aprobar."
615
+ return $OPS_RC_OFFLINE
616
+ }
617
+
618
+ # ── epic-status ──────────────────────────────────────────────────────────────────────
619
+ # [#62] Resuelve el ciclo de vida de cada propuesta del ledger. Dos fuentes por estado:
620
+ # QUEUED → mira el ack del carril directo (¿ya se despachó?) o el rechazo local.
621
+ # PROPOSED → pregunta al hub por su estado (APPROVED con `epic_code`, o REJECTED con razón).
622
+ # Es INFORMATIVO: rc 0 siempre en runtime, pase lo que pase.
623
+ cmd_epic_status() {
624
+ local only="" mode pid entries n i cid pidv st ack resp status tmp
625
+ while [ $# -gt 0 ]; do
626
+ case "$1" in
627
+ --id) only="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
628
+ *) shift ;;
629
+ esac
630
+ done
631
+ mode="$(ops_mode)"
632
+ if [ "$mode" = "dual" ]; then
633
+ echo "DUAL: no hay propuestas que consultar (el carril solo opera en modo \`runtime\`);"
634
+ echo "las épicas viven en docs/03-backlog/epicas.md"
635
+ return $OPS_RC_LEGACY
636
+ fi
637
+ if [ "$mode" != "runtime" ]; then
638
+ echo "LEGACY: no hay propuestas que consultar; las épicas viven en docs/03-backlog/epicas.md"
639
+ return $OPS_RC_LEGACY
640
+ fi
641
+ command -v python3 >/dev/null 2>&1 || { echo "sin python3: no se puede leer el ledger"; return $OPS_RC_OK; }
642
+ pid="$(runtime_field project_id)"
643
+
644
+ # Primero, la parte que NO toca la red: acks y rechazos del carril directo.
645
+ tmp="$(mktemp)" || return $OPS_RC_OK
646
+ ops_epic_ledger_read > "$tmp"
647
+ n="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["proposals"]))' "$tmp" 2>/dev/null)"
648
+ case "$n" in ''|*[!0-9]*) n=0 ;; esac
649
+ if [ "$n" -eq 0 ]; then
650
+ rm -f "$tmp"
651
+ echo "sin propuestas de épica registradas en esta copia del proyecto"
652
+ return $OPS_RC_OK
653
+ fi
654
+ i=0
655
+ while [ "$i" -lt "$n" ]; do
656
+ cid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["proposals"][int(sys.argv[2])].get("client_event_id") or "")' "$tmp" "$i" 2>/dev/null)"
657
+ pidv="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["proposals"][int(sys.argv[2])].get("proposal_id") or "")' "$tmp" "$i" 2>/dev/null)"
658
+ st="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["proposals"][int(sys.argv[2])].get("status") or "")' "$tmp" "$i" 2>/dev/null)"
659
+ i=$((i + 1))
660
+ [ -n "$only" ] && [ "$only" != "$pidv" ] && continue
661
+
662
+ if [ "$st" = "QUEUED" ] && [ -n "$cid" ]; then
663
+ ack="$(ops_epic_ack_read "$cid")"
664
+ if [ -n "$ack" ]; then
665
+ pidv="$(printf '%s' "$ack" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or {}).get("proposal_id") or "")' 2>/dev/null)"
666
+ st=PROPOSED
667
+ ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$pidv")}"
668
+ else
669
+ local rej; rej="$(ops_epic_rejected_reason "$cid")"
670
+ if [ -n "$rej" ]; then
671
+ ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"FAILED\",\"reason\":$(ops_json_str "$rej")}"
672
+ echo "$cid FAILED $rej"
673
+ continue
674
+ fi
675
+ echo "$cid QUEUED aún en la cola: se despachará al recuperar conexión"
676
+ continue
677
+ fi
678
+ fi
679
+
680
+ if [ "$st" = "PROPOSED" ] && [ -n "$pidv" ] && [ -n "$pid" ]; then
681
+ resp="$(runtime_get "$(ops_path_epic_proposal "$pid" "$(ops_urlenc "$pidv")")")"
682
+ status="$(runtime_http_status)"
683
+ case "$status" in
684
+ 200)
685
+ st="$(_claim_field "$resp" status)"
686
+ case "$st" in
687
+ APPROVED)
688
+ local code epic
689
+ code="$(_claim_field "$resp" epic_code)"
690
+ epic="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin).get("epic") or {},ensure_ascii=False))' 2>/dev/null)"
691
+ [ -n "$epic" ] || epic="{}"
692
+ ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"APPROVED\",\"epic_code\":$(ops_json_str "$code"),\"epic\":$epic}"
693
+ echo "$pidv APPROVED $code — escríbela con \`slice-ops.sh epic-writeback\`" ;;
694
+ REJECTED)
695
+ local why; why="$(_claim_field "$resp" reason)"
696
+ ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"REJECTED\",\"reason\":$(ops_json_str "$why")}"
697
+ echo "$pidv REJECTED ${why:-sin motivo registrado}" ;;
698
+ *)
699
+ echo "$pidv PROPOSED pendiente de aprobación humana en la consola del hub" ;;
700
+ esac ;;
701
+ 404)
702
+ echo "$pidv ? el hub no expone la consulta de propuestas: instancia sin soporte (hub#113 pendiente)" ;;
703
+ 401|403)
704
+ echo "$pidv ? token inválido o sin permiso ($status): habla con el ADMIN" ;;
705
+ *)
706
+ echo "$pidv ? OFFLINE: no se pudo consultar (status $status); se conserva el estado local" ;;
707
+ esac
708
+ continue
709
+ fi
710
+ echo "${pidv:-$cid} $st"
711
+ done
712
+ rm -f "$tmp"
713
+ return $OPS_RC_OK
714
+ }
715
+
716
+ # ── epic-writeback ───────────────────────────────────────────────────────────────────
717
+ # [#62] La vuelta del ciclo: aprobada la propuesta (hub#114), la épica se escribe en
718
+ # docs/03-backlog/epicas.md con el código que asignó el HUB. El fichero queda como
719
+ # PROYECCIÓN del grafo, nunca al revés — por eso el bloque es mecánico y siempre el mismo:
720
+ # una redacción libre por write-back haría que el fichero y el hub se separaran a cada pasada.
721
+ # Idempotente: si el `EP-XXX` ya tiene sección en el fichero, no se duplica (se marca hecho).
722
+ # Los códigos de HU los pone el hub si los trae; el arnés NUNCA los inventa (inventarlos es
723
+ # exactamente la colisión que motivó el issue).
724
+ # _epic_writeback_path_ok <ruta> <directorio-permitido> — imprime "OK" solo si la ruta cae
725
+ # DENTRO del directorio permitido, con symlinks y `..` ya resueltos (`realpath` sobre ambos).
726
+ # Sin python3 no se puede resolver la ruta: imprime "" (rechaza). Preferir el rechazo al
727
+ # permiso es lo correcto aquí — es una compuerta de escritura sobre ficheros ajenos.
728
+ _epic_writeback_path_ok() {
729
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
730
+ python3 - "$1" "$2" <<'PY' 2>/dev/null
731
+ import os,sys
732
+ try:
733
+ t=os.path.realpath(sys.argv[1])
734
+ base=os.path.realpath(sys.argv[2])
735
+ except Exception:
736
+ print(""); raise SystemExit(0)
737
+ print("OK" if t.startswith(base + os.sep) else "")
738
+ PY
739
+ }
740
+
741
+ cmd_epic_writeback() {
742
+ local only="" target="" mode root wrote rc
743
+ while [ $# -gt 0 ]; do
744
+ case "$1" in
745
+ --id) only="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
746
+ --file) target="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
747
+ *) shift ;;
748
+ esac
749
+ done
750
+ mode="$(ops_mode)"
751
+ if [ "$mode" = "dual" ]; then
752
+ echo "DUAL: el fichero local es primario; docs/03-backlog/epicas.md ya es la fuente y no"
753
+ echo "hay grafo del hub que proyectar sobre él (el carril solo opera en modo \`runtime\`)"
754
+ return $OPS_RC_LEGACY
755
+ fi
756
+ if [ "$mode" != "runtime" ]; then
757
+ echo "LEGACY: la épica ya vive en docs/03-backlog/epicas.md; no hay grafo que proyectar"
758
+ return $OPS_RC_LEGACY
759
+ fi
760
+ command -v python3 >/dev/null 2>&1 || { echo "⛔ sin python3 no se puede escribir el fichero" >&2; return $OPS_RC_OFFLINE; }
761
+ root="$(config_root)"
762
+ [ -n "$target" ] || target="$root/docs/03-backlog/epicas.md"
763
+ # [Ronda final · METODOLOGIA §9.2] `--file` aceptaba CUALQUIER ruta: con eso, un carve-out
764
+ # de escritura acotado a cuatro sitios se convertía en una escritura libre sobre `docs/`, y
765
+ # los Guardrails de /build:epic prometen justo lo contrario. La ruta se restringe al subárbol
766
+ # del backlog (con symlinks y `..` resueltos), y la comprobación va ANTES de la existencia:
767
+ # una ruta fuera del carve-out se rechaza exista o no.
768
+ if [ "$(_epic_writeback_path_ok "$target" "$root/docs/03-backlog")" != "OK" ]; then
769
+ echo "⛔ --file fuera del carve-out: $target" >&2
770
+ echo " El write-back solo escribe dentro de $root/docs/03-backlog/ (METODOLOGIA §9.2)." >&2
771
+ echo " Usa esa ruta (o ninguna: por defecto es docs/03-backlog/epicas.md)." >&2
772
+ return $OPS_RC_USAGE
773
+ fi
774
+ if [ ! -f "$target" ]; then
775
+ echo "⛔ no existe $target — el backlog de discovery es su dueño: créalo allí primero." >&2
776
+ return $OPS_RC_NO_SLICE
777
+ fi
778
+ # [Importante 1/2/3] fsync en AMBAS escrituras (epicas.md es un fichero de discovery que
779
+ # el arnés solo toca por carve-out: dejarlo truncado por un corte de energía es exactamente
780
+ # el daño que la restricción prohíbe); epicas.md solo se reescribe si de verdad hay algo que
781
+ # escribir (si no, cada invocación en vacío convertiría CRLF→LF y degradaría los permisos de
782
+ # 0644 a 0600 heredados del `mkstemp` de un fichero AJENO); y si el ledger no se puede
783
+ # persistir, la excepción SUBE (no se traga): reportar éxito con el ledger desincronizado
784
+ # es peor que fallar ruidosamente.
785
+ wrote="$(python3 - "$(ops_epic_ledger_path)" "$target" "$only" <<'PY' 2>/dev/null
786
+ import json,sys,os,tempfile,datetime,re
787
+ ledger,target,only=sys.argv[1],sys.argv[2],sys.argv[3]
788
+ try:
789
+ d=json.load(open(ledger))
790
+ except Exception:
791
+ d={}
792
+ ps=[p for p in (d.get("proposals") or []) if isinstance(p,dict)]
793
+ texto=open(target,encoding="utf-8").read()
794
+ escritas=[]
795
+ for p in ps:
796
+ if p.get("status")!="APPROVED" or p.get("written_back_at"):
797
+ continue
798
+ if only and p.get("proposal_id")!=only:
799
+ continue
800
+ code=p.get("epic_code")
801
+ if not code:
802
+ continue
803
+ epic=p.get("epic") or p.get("draft") or {}
804
+ # [Ronda final] La sangría permitida se alinea con RE_ENCABEZADO de graph-bundle.py
805
+ # (`^\s{0,3}#{1,6}`): con `#` exigido en columna 0, un encabezado sangrado —que el
806
+ # extractor SÍ reconoce— daba falso negativo y el write-back DUPLICABA la sección.
807
+ if re.search(r"^[ \t]{0,3}#{1,6}\s+.*\b%s\b" % re.escape(code), texto, re.M):
808
+ p["written_back_at"]=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
809
+ continue
810
+ deps=[str(x) for x in (epic.get("depends_on") or [])]
811
+ lineas=["", "## %s — %s" % (code, epic.get("title") or "sin título"), "",
812
+ "- layer: %s" % (epic.get("layer") or "business"),
813
+ "- origin: harness-writeback",
814
+ "- depende_de: [%s]" % ", ".join(deps),
815
+ "- propuesta: %s (aprobada en el hub)" % (p.get("proposal_id") or "?"),
816
+ "", "**Objetivo**: %s" % (epic.get("objective") or ""), ""]
817
+ historias=epic.get("stories") or []
818
+ if historias:
819
+ lineas.append("**Historias**")
820
+ for h in historias:
821
+ if not isinstance(h,dict):
822
+ continue
823
+ t=h.get("title") or ""
824
+ c=h.get("code")
825
+ lineas.append("- %s — %s" % (c,t) if c else "- %s" % t)
826
+ lineas.append("")
827
+ texto=texto.rstrip("\n")+"\n"+"\n".join(lineas)
828
+ p["written_back_at"]=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
829
+ escritas.append(code)
830
+ if escritas:
831
+ dirn=os.path.dirname(target) or "."
832
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".epicas.",suffix=".tmp")
833
+ try:
834
+ with os.fdopen(fd,"w",encoding="utf-8") as out:
835
+ out.write(texto)
836
+ out.flush(); os.fsync(out.fileno())
837
+ os.replace(tmp,target)
838
+ except Exception:
839
+ try: os.unlink(tmp)
840
+ except OSError: pass
841
+ raise
842
+ ldir=os.path.dirname(ledger) or "."
843
+ fd,tmp=tempfile.mkstemp(dir=ldir,prefix=".epic-proposals.",suffix=".tmp")
844
+ try:
845
+ with os.fdopen(fd,"w") as out:
846
+ json.dump({"proposals":ps},out,indent=2,ensure_ascii=False)
847
+ out.flush(); os.fsync(out.fileno())
848
+ os.replace(tmp,ledger)
849
+ except Exception:
850
+ try: os.unlink(tmp)
851
+ except OSError: pass
852
+ raise
853
+ print(" ".join(escritas))
854
+ PY
855
+ )"
856
+ rc=$?
857
+ # [Importante 3/4] rc != 0 del heredoc es un fallo de E/S (epicas.md o el ledger no se
858
+ # pudieron escribir), NUNCA "no había trabajo" — un `$wrote` vacío por éxito (rc 0, nada
859
+ # pendiente) y un `$wrote` vacío por excepción (rc != 0) son cosas distintas.
860
+ if [ "$rc" -ne 0 ]; then
861
+ echo "⛔ el write-back no se pudo completar (fallo de E/S al escribir $target o el ledger): reintenta." >&2
862
+ return $OPS_RC_OFFLINE
863
+ fi
864
+ if [ -z "$wrote" ]; then
865
+ echo "nada que escribir: no hay propuestas APPROVED pendientes (consulta \`slice-ops.sh epic-status\`)"
866
+ return $OPS_RC_NO_WORK
867
+ fi
868
+ echo "escritas en $target: $wrote"
869
+ echo "El fichero es una PROYECCIÓN del grafo del hub: no edites el código a mano."
870
+ echo "Las HU sin código las numera discovery (\`/trycore:*\`): el arnés no las inventa."
871
+ return $OPS_RC_OK
872
+ }
873
+
429
874
  # ── status ───────────────────────────────────────────────────────────────────────────
430
875
  # Informe local del agente: modo, proyecto (con sus hechos: scaffold/fuente de diseño/
431
876
  # tipo/caparazón — la precondición temprana de /build:slice y /build:work la evalúan de
@@ -465,10 +910,44 @@ cmd_status() {
465
910
  else
466
911
  echo "contexto: $lock ✓ v$(projection_get context.version "?") (${age}s)"
467
912
  fi
913
+ # [#63] La versión de grafo es la foto del BACKLOG. Solo se menciona si el hub versiona:
914
+ # inventarla en una instancia antigua sería ruido sin significado.
915
+ local gv gs gserver
916
+ gv="$(projection_get context.graph_version "")"
917
+ if [ -n "$gv" ]; then
918
+ gs="$(runtime_graph_status_read)"
919
+ gserver="$(printf '%s' "$gs" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("server") or "")' 2>/dev/null)"
920
+ # [Ronda de arreglo 1, Important 3] La comparación es «servidor MAYOR que local», no
921
+ # «distinto»: un marcador de desfase que se quedó pegado con un `server` viejo (porque el
922
+ # carril directo no lo limpiaba al despachar con éxito — ya corregido) podía apuntar a una
923
+ # versión POR DEBAJO de la local, y `!=` mentía diciendo «por detrás» con el local por
924
+ # delante. `2>/dev/null` deja la comparación fail-open si algún valor no es entero.
925
+ if [ -n "$gserver" ] && [ "$gserver" -gt "$gv" ] 2>/dev/null; then
926
+ echo "grafo: v$gv ⚠ por detrás del hub (v$gserver): refresca antes de reclamar o proponer"
927
+ else
928
+ echo "grafo: v$gv"
929
+ fi
930
+ fi
468
931
  echo "lease: expira $(projection_get lease.expires_at "?")"
469
- if [ -f "$(config_root)/.claude/state/heartbeat-status.json" ]; then
470
- grep -q '"lease_lost": *true' "$(config_root)/.claude/state/heartbeat-status.json" 2>/dev/null \
932
+ local hbf hbstatus
933
+ hbf="$(config_root)/.claude/state/heartbeat-status.json"
934
+ if [ -f "$hbf" ]; then
935
+ grep -q '"lease_lost": *true' "$hbf" 2>/dev/null \
471
936
  && echo " ⚠ lease perdido: haz checkpoint y vuelve a reclamar"
937
+ # [#61] Una caché vieja porque la red está caída NO es lo mismo que una caché vieja
938
+ # porque nadie la pidió: la primera exige no cerrar gates a ciegas.
939
+ if grep -q '"context_stale": *true' "$hbf" 2>/dev/null; then
940
+ hbstatus="$(command -v python3 >/dev/null 2>&1 && python3 - "$hbf" <<'PY' 2>/dev/null
941
+ import json,sys
942
+ try:
943
+ print(json.load(open(sys.argv[1])).get("last_context_status"))
944
+ except Exception:
945
+ print("?")
946
+ PY
947
+ )"
948
+ echo " ⚠ el heartbeat no pudo refrescar /agent/context (último intento: ${hbstatus:-?})"
949
+ echo " se opera con la última proyección buena; no cierres gates a ciegas"
950
+ fi
472
951
  fi
473
952
  pend="$(find "$(runtime_outbox_dir)" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' 2>/dev/null | wc -l | tr -d ' ')"
474
953
  echo "cola: $pend evento(s) pendiente(s) de despacho"
@@ -1075,6 +1554,9 @@ case "$SUB" in
1075
1554
  checkpoint) cmd_checkpoint "$@"; exit $? ;;
1076
1555
  fact) cmd_fact "$@"; exit $? ;;
1077
1556
  propose-asset) cmd_propose_asset "$@"; exit $? ;;
1557
+ propose-epic) cmd_propose_epic "$@"; exit $? ;;
1558
+ epic-status) cmd_epic_status "$@"; exit $? ;;
1559
+ epic-writeback) cmd_epic_writeback "$@"; exit $? ;;
1078
1560
  status) cmd_status "$@"; exit $? ;;
1079
1561
  escalate) cmd_escalate "$@"; exit $? ;;
1080
1562
  ""|-h|--help|help) usage; exit $OPS_RC_USAGE ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycore/spec-build-harness",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,4 +22,45 @@ got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" config_get runtime.mode legacy)"
22
22
  got="$(bash -c 'source "$1/hooks/build/lib/state-io.sh" 2>/dev/null && BUILD_CONFIG_FILE="$2" config_get context.warning_pct 35' _ "$ROOT" "$TMP/cfg.json" 2>&1)"
23
23
  [ "$got" = 40 ] && echo "OK state-io re-exporta config_get" || { echo "FAIL state-io no re-exporta ($got)"; fail=1; }
24
24
 
25
+ # ── config_root en worktrees ──────────────────────────────────────────────────
26
+ # El estado del arnés está en .gitignore (secretos + working state), así que NO
27
+ # viaja a un `git worktree add`. Resolviendo la raíz solo por --show-toplevel, el
28
+ # agente que arranca en un worktree se queda sin credenciales y opera huérfano:
29
+ # sin identidad, sin lease y con los guards inhibidos. Nadie lo probaba, y por eso
30
+ # sobrevivió hasta 0.13.0.
31
+ # `git rev-parse --git-common-dir` devuelve la ruta ya resuelta, y en macOS /var es
32
+ # un symlink a /private/var: se compara contra la ruta FÍSICA (pwd -P) para no
33
+ # medir la forma en que se escribió el path.
34
+ MAIN="$(mkdir -p "$TMP/main" && cd "$TMP/main" && pwd -P)"
35
+ mkdir -p "$MAIN/.claude/state"
36
+ git -C "$MAIN" init -q 2>/dev/null
37
+ git -C "$MAIN" config user.email t@t.io; git -C "$MAIN" config user.name t
38
+ echo x > "$MAIN/f"; git -C "$MAIN" add -A >/dev/null 2>&1; git -C "$MAIN" commit -qm init >/dev/null 2>&1
39
+ echo '{"runtime_url":"http://localhost"}' > "$MAIN/.claude/state/runtime.credentials"
40
+ WT="$TMP/wt" # lo crea `git worktree add`, se normaliza al usarlo
41
+ git -C "$MAIN" worktree add -q "$WT" -b wt-test >/dev/null 2>&1
42
+
43
+ got="$(cd "$WT" && unset CLAUDE_PROJECT_DIR; config_root)"
44
+ [ "$got" = "$MAIN" ] && echo "OK config_root cae al clon principal desde un worktree" \
45
+ || { echo "FAIL config_root en worktree ($got, esperado $MAIN)"; fail=1; }
46
+
47
+ # Con CLAUDE_PROJECT_DIR apuntando al worktree (que es lo que hace el harness de
48
+ # Claude Code al abrir una sesión ahí) el resultado debe ser el mismo: la variable
49
+ # dice dónde trabaja el agente, no dónde vive el estado del arnés.
50
+ got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_root)"
51
+ [ "$got" = "$MAIN" ] && echo "OK config_root ignora CLAUDE_PROJECT_DIR sin estado" \
52
+ || { echo "FAIL config_root con CLAUDE_PROJECT_DIR ($got, esperado $MAIN)"; fail=1; }
53
+
54
+ # Idempotencia: en un clon normal con estado propio, nada cambia.
55
+ got="$(cd "$MAIN" && CLAUDE_PROJECT_DIR="$MAIN" config_root)"
56
+ [ "$got" = "$MAIN" ] && echo "OK config_root respeta el clon con estado propio" \
57
+ || { echo "FAIL config_root en clon normal ($got)"; fail=1; }
58
+
59
+ # Y sin estado en ninguna parte (proyecto que aún no usa el arnés) se devuelve el
60
+ # candidato tal cual: no se inventa una raíz ajena.
61
+ BARE="$TMP/bare"; mkdir -p "$BARE"
62
+ got="$(cd "$BARE" && CLAUDE_PROJECT_DIR="$BARE" config_root)"
63
+ [ "$got" = "$BARE" ] && echo "OK config_root sin estado devuelve el candidato" \
64
+ || { echo "FAIL config_root sin estado ($got)"; fail=1; }
65
+
25
66
  exit $fail