@trycore/spec-build-harness 0.12.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/METODOLOGIA.md +6 -1
  3. package/README.md +1 -0
  4. package/VERSION +1 -1
  5. package/agents/build/dor-dod-gatekeeper.md +10 -3
  6. package/commands/build/epic.md +109 -0
  7. package/commands/build/onboard.md +59 -17
  8. package/commands/build/slice.md +4 -2
  9. package/commands/build/work.md +6 -2
  10. package/config/build-config.template.json +2 -1
  11. package/dist/commands/init.js +13 -0
  12. package/dist/commands/status.js +13 -1
  13. package/dist/lib/normalize.js +11 -0
  14. package/dist/lib/paths.js +1 -0
  15. package/dist/lib/runtime-client.js +20 -0
  16. package/dist/lib/state-bundle.js +15 -1
  17. package/docs/commands.md +2 -1
  18. package/docs/hooks.md +11 -1
  19. package/docs/runtime/protocolo-cliente-runtime.md +90 -0
  20. package/hooks/build/design-source-guard.sh +12 -1
  21. package/hooks/build/heartbeat.sh +64 -4
  22. package/hooks/build/lib/agent-context.sh +16 -7
  23. package/hooks/build/lib/config.sh +25 -1
  24. package/hooks/build/lib/runtime-client.sh +540 -9
  25. package/hooks/build/lib/runtime-ops.sh +108 -0
  26. package/hooks/build/scaffold-guard.sh +14 -1
  27. package/hooks/build/slice-ops.sh +498 -16
  28. package/package.json +1 -1
  29. package/scripts/lib/graph-bundle.py +165 -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 +574 -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
@@ -30,6 +30,29 @@ source "$ROOT/hooks/build/lib/runtime-ops.sh" 2>/dev/null || { echo "FAIL no exi
30
30
  [ "$(ops_path_mirror p-1)" = "/projects/p-1/mirror/transitions" ] && echo "OK ops_path_mirror" || { echo "FAIL ops_path_mirror"; fail=1; }
31
31
  [ "$(ops_path_front_integration f-1 ak-1)" = "/fronts/f-1/members/ak-1/integration" ] && echo "OK ops_path_front_integration" || { echo "FAIL ops_path_front_integration"; fail=1; }
32
32
 
33
+ # [#62] Tabla de rutas de la propuesta de épica: un solo sitio donde se escriben rutas.
34
+ [ "$(ops_path_epic_proposals p-1)" = "/projects/p-1/epic-proposals" ] \
35
+ && echo "OK ops_path_epic_proposals" || { echo "FAIL ops_path_epic_proposals"; fail=1; }
36
+ [ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/epic-proposals/prop-7" ] \
37
+ && echo "OK ops_path_epic_proposal" || { echo "FAIL ops_path_epic_proposal"; fail=1; }
38
+
39
+ # [#62] Ledger: upsert por client_event_id, sin duplicar, conservando lo no mencionado.
40
+ rm -f "$(ops_epic_ledger_path)"
41
+ ops_epic_ledger_upsert '{"client_event_id":"cid-1","status":"QUEUED","draft":{"title":"Notificaciones"}}'
42
+ ops_epic_ledger_upsert '{"client_event_id":"cid-1","status":"PROPOSED","proposal_id":"prop-7"}'
43
+ ops_epic_ledger_upsert '{"client_event_id":"cid-2","status":"QUEUED"}'
44
+ ops_epic_ledger_read | python3 -c "
45
+ import json,sys
46
+ d=json.load(sys.stdin); ps=d['proposals']
47
+ e=[p for p in ps if p['client_event_id']=='cid-1'][0]
48
+ ok = len(ps)==2 and e['status']=='PROPOSED' and e['proposal_id']=='prop-7' \
49
+ and e['draft']['title']=='Notificaciones'
50
+ print('SI' if ok else 'NO')" | grep -q SI \
51
+ && echo "OK el ledger hace upsert sin duplicar ni perder el borrador" \
52
+ || { echo "FAIL upsert del ledger"; fail=1; }
53
+ [ "$(PATH=/var/empty ops_epic_ledger_read)" = '{"proposals":[]}' ] \
54
+ && echo "OK el ledger sin python3 devuelve vacio" || { echo "FAIL ledger sin python3"; fail=1; }
55
+
33
56
  # --- ops_json_str: comillas, saltos de línea y tildes por STDIN (nunca por argv) ---
34
57
  got="$(ops_json_str 'una "cita" con ñ y
35
58
  salto')"
@@ -416,6 +439,53 @@ echo "$out" | grep -q "slice_archived" && echo "$out" | grep -q "archivar exige
416
439
  && echo "OK status muestra tipo y razón del rechazo más reciente" || { echo "FAIL status sin razón del último rechazo: $out"; fail=1; }
417
440
  rm -rf "$PROJ/.claude/state/outbox/rejected"
418
441
 
442
+ # [#63] status muestra la versión de grafo y avisa si va por detrás.
443
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v2","version":9,"graph_version":42}}'
444
+ runtime_graph_clear_stale
445
+ out="$(bash "$SO" status --no-refresh 2>&1)"
446
+ echo "$out" | grep -q 'grafo: *v42' && echo "OK status muestra la version de grafo" \
447
+ || { echo "FAIL status sin version de grafo"; fail=1; }
448
+ echo "$out" | grep -qi 'por detrás' && { echo "FAIL status avisa de desfase inexistente"; fail=1; } \
449
+ || echo "OK status calla sin desfase"
450
+ runtime_graph_note_stale 42 45
451
+ out="$(bash "$SO" status --no-refresh 2>&1)"
452
+ echo "$out" | grep -qi 'por detrás' && echo "$out" | grep -q '45' \
453
+ && echo "OK status avisa del desfase de grafo" || { echo "FAIL status sin aviso de desfase"; fail=1; }
454
+ # [Ronda de arreglo 1, Important 3] Un marcador de desfase VIEJO (el carril directo no lo
455
+ # limpiaba al despachar con éxito) puede quedar apuntando a una versión POR DEBAJO de la
456
+ # local, si la proyección avanzó después. `!=` mentiría diciendo "por detrás" con el local
457
+ # por delante; la comparación correcta es "servidor > local".
458
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v2","version":9,"graph_version":50}}'
459
+ out="$(bash "$SO" status --no-refresh 2>&1)"
460
+ echo "$out" | grep -q 'grafo: *v50' && ! echo "$out" | grep -qi 'por detrás' \
461
+ && echo "OK un marcador de desfase con server < local no miente diciendo «por detrás»" \
462
+ || { echo "FAIL status avisa de desfase con el local por delante: $out"; fail=1; }
463
+ runtime_graph_clear_stale
464
+ # Hub que no versiona: ni línea de grafo ni ruido.
465
+ runtime_graph_clear_stale
466
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v2","version":9}}'
467
+ out="$(bash "$SO" status --no-refresh 2>&1)"
468
+ echo "$out" | grep -q 'grafo:' && { echo "FAIL status inventa version de grafo"; fail=1; } \
469
+ || echo "OK sin hub que versione, status no menciona el grafo"
470
+
471
+ # [#61] status distingue «cache vieja» de «el heartbeat no pudo refrescarla».
472
+ python3 -c "
473
+ import json
474
+ json.dump({'lease_lost': False, 'context_stale': True, 'last_context_status': '000'},
475
+ open('$PROJ/.claude/state/heartbeat-status.json','w'), indent=2)"
476
+ out="$(bash "$ROOT/hooks/build/slice-ops.sh" status --no-refresh 2>&1)"
477
+ echo "$out" | grep -q 'no pudo refrescar /agent/context' \
478
+ && echo "OK status avisa del refresco fallido del heartbeat" \
479
+ || { echo "FAIL status no avisa del refresco fallido"; fail=1; }
480
+ python3 -c "
481
+ import json
482
+ json.dump({'lease_lost': False, 'context_stale': False, 'last_context_status': 200},
483
+ open('$PROJ/.claude/state/heartbeat-status.json','w'), indent=2)"
484
+ out="$(bash "$ROOT/hooks/build/slice-ops.sh" status --no-refresh 2>&1)"
485
+ echo "$out" | grep -q 'no pudo refrescar /agent/context' \
486
+ && { echo "FAIL status avisa de un refresco sano"; fail=1; } \
487
+ || echo "OK status calla con el refresco sano"
488
+
419
489
  # status es informativo: NUNCA falla aunque no haya nada.
420
490
  rm -f "$PROJ/.claude/state/runtime-projection.json"
421
491
  rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" status --no-refresh)" || rc=$?
@@ -1545,6 +1615,324 @@ PYX
1545
1615
  rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
1546
1616
  runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
1547
1617
 
1618
+ # ══════════════ slice-ops.sh: propose-epic / epic-status / epic-writeback [#62] ══════════════
1619
+ # Stub PROPIO (puerto, routes-ep.json y calls-ep.jsonl): routes-cl.json/STUB_CL ya no existen
1620
+ # en este punto del fichero y mutarlos rompería aserciones aguas arriba.
1621
+ PORT_EP="$(freeport)"
1622
+ cat > "$TMP/routes-ep.json" <<JSON
1623
+ {"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-77", "status": "PROPOSED"}}}
1624
+ JSON
1625
+ : > "$TMP/calls-ep.jsonl"
1626
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_EP" "$TMP/routes-ep.json" "$TMP/calls-ep.jsonl" &
1627
+ STUB_EP=$!
1628
+ STUBS="$STUBS $STUB_EP"
1629
+ sleep 0.3
1630
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_EP\",\"project_token\":\"tok-ep\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\"}"
1631
+
1632
+ # --- [#62] propose-epic ---
1633
+ cat > "$TMP/draft.json" <<'JSON'
1634
+ {"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1635
+ "layer":"business","files_scope":["src/notifications/**"],"depends_on":["EP-012"],
1636
+ "epic_code":"EP-999",
1637
+ "stories":[{"title":"ver el aviso","acceptance_criteria":"Dado … Cuando … Entonces …"}]}
1638
+ JSON
1639
+
1640
+ # legacy y dual: rc 3 sin efectos (ni cola, ni red).
1641
+ rm -rf "$PROJ/.claude/state/outbox"; rm -f "$(ops_epic_ledger_path)"
1642
+ TRYCORE_RUNTIME_MODE=legacy bash "$SO" propose-epic --file "$TMP/draft.json" >/dev/null 2>&1
1643
+ [ $? -eq 3 ] && echo "OK propose-epic rc 3 en legacy" || { echo "FAIL propose-epic legacy"; fail=1; }
1644
+ out="$(TRYCORE_RUNTIME_MODE=dual bash "$SO" propose-epic --file "$TMP/draft.json" 2>&1)"; rc=$?
1645
+ [ $rc -eq 3 ] && echo "OK propose-epic rc 3 en dual" || { echo "FAIL propose-epic dual"; fail=1; }
1646
+ # [Ronda final] En `dual` SÍ hay hub (el proyecto está registrado; el fichero es primario):
1647
+ # el mensaje de legacy mandaba a buscar un problema de conexión inexistente.
1648
+ echo "$out" | grep -qi 'no hay hub' \
1649
+ && { echo "FAIL propose-epic dice \"no hay hub\" en modo dual"; fail=1; } \
1650
+ || echo "OK propose-epic en dual no niega la existencia del hub"
1651
+ echo "$out" | grep -qi 'dual' && echo "OK propose-epic en dual nombra el modo" \
1652
+ || { echo "FAIL el mensaje de dual no nombra el modo: $out"; fail=1; }
1653
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
1654
+ [ "${n:-0}" = 0 ] && echo "OK propose-epic no encola nada en legacy|dual" || { echo "FAIL efectos en legacy|dual"; fail=1; }
1655
+
1656
+ # runtime online: se envía y devuelve el identificador del hub.
1657
+ out="$(bash "$SO" propose-epic --file "$TMP/draft.json" 2>&1)"; rc=$?
1658
+ [ $rc -eq 0 ] && echo "$out" | grep -q 'prop-77' \
1659
+ && echo "OK propose-epic envia y devuelve el identificador" \
1660
+ || { echo "FAIL propose-epic online (rc $rc): $out"; fail=1; }
1661
+
1662
+ # El EP-XXX del borrador se IGNORA (la identidad la asigna el hub, hub#113) — ignorar, no rechazar.
1663
+ body="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
1664
+ import json,sys
1665
+ for l in open(sys.argv[1]):
1666
+ d=json.loads(l)
1667
+ if d["path"]=="/projects/p-1/epic-proposals":
1668
+ b=json.loads(d["body"])
1669
+ print("SI" if "epic_code" not in b and b.get("title","").startswith("Notificaciones") else "NO")
1670
+ break
1671
+ PY
1672
+ )"
1673
+ [ "$body" = SI ] && echo "OK propose-epic ignora el EP-XXX del borrador" \
1674
+ || { echo "FAIL el EP-XXX viajo al hub ($body)"; fail=1; }
1675
+ echo "$out" | grep -qi 'ignorado' && echo "OK propose-epic avisa de que ignoro el codigo" \
1676
+ || { echo "FAIL sin aviso del codigo ignorado"; fail=1; }
1677
+
1678
+ # El ledger recuerda la propuesta con su borrador.
1679
+ ops_epic_ledger_read | python3 -c "
1680
+ import json,sys
1681
+ p=json.load(sys.stdin)['proposals'][0]
1682
+ print('SI' if p['status']=='PROPOSED' and p['proposal_id']=='prop-77' and p['draft']['title'].startswith('Notificaciones') else 'NO')" \
1683
+ | grep -q SI && echo "OK el ledger registra la propuesta enviada" \
1684
+ || { echo "FAIL el ledger no registro la propuesta"; fail=1; }
1685
+
1686
+ # Offline: queda encolada y se despacha al reconectar.
1687
+ kill "$STUB_EP" 2>/dev/null; wait "$STUB_EP" 2>/dev/null
1688
+ out="$(bash "$SO" propose-epic --file "$TMP/draft.json" 2>&1)"; rc=$?
1689
+ [ $rc -eq 5 ] && echo "OK propose-epic offline rc 5" || { echo "FAIL propose-epic offline (rc $rc): $out"; fail=1; }
1690
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
1691
+ [ "${n:-0}" -ge 1 ] && echo "OK propose-epic offline deja la propuesta en la cola" \
1692
+ || { echo "FAIL la propuesta offline se perdio"; fail=1; }
1693
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_EP" "$TMP/routes-ep.json" "$TMP/calls-ep.jsonl" &
1694
+ STUB_EP=$!
1695
+ STUBS="$STUBS $STUB_EP"
1696
+ sleep 0.3
1697
+ rm -f "$PROJ/.claude/state/outbox/.dispatch-state.json"
1698
+ runtime_dispatch_outbox >/dev/null 2>&1
1699
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
1700
+ [ "${n:-0}" = 0 ] && echo "OK la propuesta encolada se despacha al reconectar" \
1701
+ || { echo "FAIL la propuesta encolada no se despacho ($n)"; fail=1; }
1702
+
1703
+ # Uso: sin --file, rc 2.
1704
+ bash "$SO" propose-epic >/dev/null 2>&1
1705
+ [ $? -eq 2 ] && echo "OK propose-epic sin --file rc 2" || { echo "FAIL propose-epic uso"; fail=1; }
1706
+ # Borrador sin título: rc 2 con mensaje accionable (compuerta local, sin gastar red).
1707
+ echo '{"objective":"algo"}' > "$TMP/malo.json"
1708
+ out="$(bash "$SO" propose-epic --file "$TMP/malo.json" 2>&1)"
1709
+ [ $? -eq 2 ] && echo "$out" | grep -qi 'title' && echo "OK propose-epic valida el borrador en local" \
1710
+ || { echo "FAIL validacion local del borrador: $out"; fail=1; }
1711
+
1712
+ # --- [#62] epic-status ---
1713
+ python3 - "$TMP/routes-ep.json" <<'PY'
1714
+ import json,sys
1715
+ p=sys.argv[1]; d=json.load(open(p))
1716
+ d["GET /projects/p-1/epic-proposals/prop-77"]={"status":200,"body":{
1717
+ "proposal_id":"prop-77","status":"APPROVED","epic_code":"EP-045",
1718
+ "epic":{"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1719
+ "layer":"business","depends_on":["EP-012"],
1720
+ "stories":[{"code":"HU-310","title":"ver el aviso"}]}}}
1721
+ json.dump(d,open(p,"w"))
1722
+ PY
1723
+ TRYCORE_RUNTIME_MODE=legacy bash "$SO" epic-status >/dev/null 2>&1
1724
+ [ $? -eq 3 ] && echo "OK epic-status rc 3 en legacy" || { echo "FAIL epic-status legacy"; fail=1; }
1725
+ out="$(TRYCORE_RUNTIME_MODE=dual bash "$SO" epic-status 2>&1)"; rc=$?
1726
+ [ $rc -eq 3 ] && echo "$out" | grep -qi 'dual' \
1727
+ && echo "OK epic-status en dual rc 3 con el texto del modo" \
1728
+ || { echo "FAIL epic-status en dual (rc $rc): $out"; fail=1; }
1729
+
1730
+ out="$(bash "$SO" epic-status 2>&1)"; rc=$?
1731
+ [ $rc -eq 0 ] && echo "$out" | grep -q 'EP-045' \
1732
+ && echo "OK epic-status resuelve la aprobacion y muestra el codigo" \
1733
+ || { echo "FAIL epic-status (rc $rc): $out"; fail=1; }
1734
+ ops_epic_ledger_read | python3 -c "
1735
+ import json,sys
1736
+ p=json.load(sys.stdin)['proposals'][0]
1737
+ print('SI' if p['status']=='APPROVED' and p['epic_code']=='EP-045' and p['epic']['stories'][0]['code']=='HU-310' else 'NO')" \
1738
+ | grep -q SI && echo "OK epic-status guarda el codigo y el contenido aprobado" \
1739
+ || { echo "FAIL el ledger no recogio la aprobacion"; fail=1; }
1740
+
1741
+ # Un hub sin soporte (404) se dice con esas palabras y NO rompe nada.
1742
+ python3 - "$TMP/routes-ep.json" <<'PY'
1743
+ import json,sys
1744
+ p=sys.argv[1]; d=json.load(open(p))
1745
+ d.pop("GET /projects/p-1/epic-proposals/prop-77",None)
1746
+ json.dump(d,open(p,"w"))
1747
+ PY
1748
+ ops_epic_ledger_upsert '{"client_event_id":"cid-x","proposal_id":"prop-77","status":"PROPOSED"}'
1749
+ out="$(bash "$SO" epic-status 2>&1)"; rc=$?
1750
+ [ $rc -eq 0 ] && echo "$out" | grep -qi 'sin soporte' \
1751
+ && echo "OK epic-status nombra la falta de soporte del hub" \
1752
+ || { echo "FAIL epic-status con 404 (rc $rc): $out"; fail=1; }
1753
+
1754
+ # --- [#62] epic-writeback: el fichero es proyección del grafo, nunca al revés ---
1755
+ mkdir -p "$PROJ/docs/03-backlog"
1756
+ cat > "$PROJ/docs/03-backlog/epicas.md" <<'MD'
1757
+ # Épicas
1758
+
1759
+ ## EP-012 — Autenticación
1760
+
1761
+ **Objetivo**: entrar a la aplicación.
1762
+ MD
1763
+ # Restaura el ledger con una propuesta APROBADA (el 404 del test anterior no la tocó).
1764
+ rm -f "$(ops_epic_ledger_path)"
1765
+ ops_epic_ledger_upsert '{"client_event_id":"cid-w","proposal_id":"prop-77","status":"APPROVED",
1766
+ "epic_code":"EP-045","written_back_at":null,
1767
+ "epic":{"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
1768
+ "layer":"business","depends_on":["EP-012"],"stories":[{"code":"HU-310","title":"ver el aviso"},{"title":"marcar como leído"}]}}'
1769
+
1770
+ TRYCORE_RUNTIME_MODE=legacy bash "$SO" epic-writeback >/dev/null 2>&1
1771
+ [ $? -eq 3 ] && echo "OK epic-writeback rc 3 en legacy" || { echo "FAIL epic-writeback legacy"; fail=1; }
1772
+ out="$(TRYCORE_RUNTIME_MODE=dual bash "$SO" epic-writeback 2>&1)"; rc=$?
1773
+ [ $rc -eq 3 ] && echo "$out" | grep -qi 'dual' \
1774
+ && echo "OK epic-writeback en dual rc 3 con el texto del modo" \
1775
+ || { echo "FAIL epic-writeback en dual (rc $rc): $out"; fail=1; }
1776
+
1777
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
1778
+ [ $rc -eq 0 ] && echo "$out" | grep -q 'EP-045' \
1779
+ && echo "OK epic-writeback escribe la epica aprobada" || { echo "FAIL epic-writeback (rc $rc): $out"; fail=1; }
1780
+ grep -q '^## EP-045 — Notificaciones en tiempo real$' "$PROJ/docs/03-backlog/epicas.md" \
1781
+ && echo "OK el encabezado usa el codigo que asigno el hub" \
1782
+ || { echo "FAIL encabezado del write-back"; fail=1; }
1783
+ grep -q 'depende_de: \[EP-012\]' "$PROJ/docs/03-backlog/epicas.md" \
1784
+ && echo "OK el write-back deja depende_de estructurado (lo lee graph-bundle)" \
1785
+ || { echo "FAIL depende_de ausente"; fail=1; }
1786
+ grep -q 'origin: harness-writeback' "$PROJ/docs/03-backlog/epicas.md" \
1787
+ && echo "OK el write-back se identifica en el frontmatter" || { echo "FAIL origin ausente"; fail=1; }
1788
+ grep -q 'HU-310 — ver el aviso' "$PROJ/docs/03-backlog/epicas.md" \
1789
+ && echo "OK las historias con codigo del hub se escriben con el" \
1790
+ || { echo "FAIL historia con codigo"; fail=1; }
1791
+ grep -q '^- marcar como leído$' "$PROJ/docs/03-backlog/epicas.md" \
1792
+ && echo "OK las historias sin codigo se escriben sin inventarlo" \
1793
+ || { echo "FAIL historia sin codigo"; fail=1; }
1794
+ python3 -c "
1795
+ import re
1796
+ t=open('$PROJ/docs/03-backlog/epicas.md').read()
1797
+ print(len(re.findall(r'^## EP-045 ', t, re.M)))" | grep -qx 1 \
1798
+ && echo "OK una sola seccion EP-045" || { echo "FAIL secciones duplicadas"; fail=1; }
1799
+
1800
+ # [Importante 2] captura el estado del fichero AJENO antes del paso en vacío: ni un byte
1801
+ # ni el modo (mkstemp crea con 0600; una reescritura innecesaria degradaría los permisos
1802
+ # del backlog del consumidor) ni el inodo deben cambiar cuando no hay nada que escribir.
1803
+ before_stat="$(python3 -c "import os; st=os.stat('$PROJ/docs/03-backlog/epicas.md'); print(st.st_ino, oct(st.st_mode))")"
1804
+ before_bytes="$(cat "$PROJ/docs/03-backlog/epicas.md")"
1805
+
1806
+ # Idempotente: una segunda pasada no duplica y no tiene nada que hacer.
1807
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
1808
+ [ $rc -eq 7 ] && echo "OK epic-writeback rc 7 sin nada pendiente" \
1809
+ || { echo "FAIL epic-writeback repetido (rc $rc): $out"; fail=1; }
1810
+ python3 -c "
1811
+ import re
1812
+ t=open('$PROJ/docs/03-backlog/epicas.md').read()
1813
+ print(len(re.findall(r'^## EP-045 ', t, re.M)))" | grep -qx 1 \
1814
+ && echo "OK el write-back es idempotente" || { echo "FAIL el write-back duplico la epica"; fail=1; }
1815
+ ops_epic_ledger_read | python3 -c "
1816
+ import json,sys
1817
+ p=json.load(sys.stdin)['proposals'][0]
1818
+ print('SI' if p.get('written_back_at') else 'NO')" | grep -q SI \
1819
+ && echo "OK el ledger marca el write-back hecho" || { echo "FAIL written_back_at sin marcar"; fail=1; }
1820
+
1821
+ after_stat="$(python3 -c "import os; st=os.stat('$PROJ/docs/03-backlog/epicas.md'); print(st.st_ino, oct(st.st_mode))")"
1822
+ after_bytes="$(cat "$PROJ/docs/03-backlog/epicas.md")"
1823
+ [ "$before_stat" = "$after_stat" ] && echo "OK epic-writeback rc 7 no toca inodo/permisos de epicas.md (fichero ajeno intacto)" \
1824
+ || { echo "FAIL epic-writeback rc 7 reescribio epicas.md (antes: $before_stat / despues: $after_stat)"; fail=1; }
1825
+ [ "$before_bytes" = "$after_bytes" ] && echo "OK epic-writeback rc 7 no cambia ni un byte de epicas.md" \
1826
+ || { echo "FAIL epic-writeback rc 7 cambio el contenido de epicas.md"; fail=1; }
1827
+
1828
+ # [issue #62 · hueco de cobertura] el brief garantiza que graph-bundle.py --from-docs sigue
1829
+ # leyendo el bloque que epic-writeback acaba de escribir: se prueba ANTES de que el siguiente
1830
+ # bloque borre epicas.md.
1831
+ deps_out="$(python3 "$ROOT/scripts/lib/graph-bundle.py" --print-deps "$PROJ/docs/03-backlog/epicas.md" 2>"$TMP/gb-writeback.err")"
1832
+ gb_rc=$?
1833
+ [ $gb_rc -eq 0 ] && echo "$deps_out" | python3 -c "
1834
+ import json,sys
1835
+ d=json.load(sys.stdin)
1836
+ print('SI' if d.get('EP-045')==['EP-012'] else 'NO')" | grep -q SI \
1837
+ && echo "OK graph-bundle.py --from-docs reconoce la seccion EP-045 escrita por epic-writeback y su dependencia de EP-012" \
1838
+ || { echo "FAIL graph-bundle no leyo el write-back (rc $gb_rc): $deps_out"; fail=1; }
1839
+
1840
+ # [Importante 3/4] un fallo de E/S al escribir epicas.md o el ledger se anuncia como fallo,
1841
+ # JAMÁS como "nada que escribir" (benigno) ni como éxito con el ledger desincronizado.
1842
+ # Se omite bajo root: chmod no restringe la escritura y el fixture no sería significativo.
1843
+ if [ "$(id -u)" != 0 ]; then
1844
+ ops_epic_ledger_upsert '{"client_event_id":"cid-io1","proposal_id":"prop-io1","status":"APPROVED",
1845
+ "epic_code":"EP-050","written_back_at":null,
1846
+ "epic":{"title":"Fallo de E-S","objective":"probar el fallo de E-S","layer":"business","depends_on":[]}}'
1847
+
1848
+ # (a) epicas.md no se puede escribir (directorio de solo lectura).
1849
+ chmod 555 "$PROJ/docs/03-backlog"
1850
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
1851
+ chmod 755 "$PROJ/docs/03-backlog"
1852
+ [ $rc -eq 5 ] && echo "OK epic-writeback rc 5 si epicas.md no se puede escribir (fallo de E/S, no rc 7)" \
1853
+ || { echo "FAIL epic-writeback con docs de solo lectura (rc $rc): $out"; fail=1; }
1854
+ echo "$out" | grep -qi 'nada que escribir' \
1855
+ && { echo "FAIL el fallo de E/S al escribir epicas.md se reporto como \"nada que escribir\""; fail=1; } \
1856
+ || echo "OK el fallo de E/S al escribir epicas.md NO se confunde con \"nada que escribir\""
1857
+
1858
+ # (b) epicas.md SÍ se puede escribir pero el ledger no (directorio de solo lectura):
1859
+ # no debe reportarse éxito con el ledger sin persistir.
1860
+ chmod 555 "$PROJ/.claude/state"
1861
+ out2="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc2=$?
1862
+ chmod 755 "$PROJ/.claude/state"
1863
+ [ $rc2 -eq 5 ] && echo "OK epic-writeback rc 5 si el ledger no se puede persistir (no rc 0)" \
1864
+ || { echo "FAIL epic-writeback con ledger de solo lectura (rc $rc2): $out2"; fail=1; }
1865
+ echo "$out2" | grep -q '^escritas en' \
1866
+ && { echo "FAIL reporto \"escritas en...\" (exito) con el ledger sin persistir"; fail=1; } \
1867
+ || echo "OK epic-writeback no reporta exito si el ledger no se pudo persistir"
1868
+
1869
+ # Descarta el fixture: no debe interferir con el resto de la suite.
1870
+ ops_epic_ledger_upsert '{"client_event_id":"cid-io1","status":"REJECTED","reason":"fixture de test descartada"}'
1871
+ else
1872
+ echo "OK (omitido bajo root, chmod no restringe) fallo de E/S de epic-writeback en epicas.md"
1873
+ echo "OK (omitido bajo root, chmod no restringe) fallo de E/S de epic-writeback en el ledger"
1874
+ fi
1875
+
1876
+ # [Ronda final · B4] Un encabezado SANGRADO ya existente cuenta como sección presente: el
1877
+ # extractor de graph-bundle.py acepta `\s{0,3}` de sangría y el write-back exigía `#` en
1878
+ # columna 0 — el falso negativo DUPLICABA la épica en el fichero.
1879
+ cat > "$PROJ/docs/03-backlog/epicas.md" <<'MD'
1880
+ # Épicas
1881
+
1882
+ ## EP-060 — Sangrada a mano
1883
+
1884
+ **Objetivo**: existir con dos espacios delante.
1885
+ MD
1886
+ ops_epic_ledger_upsert '{"client_event_id":"cid-ind","proposal_id":"prop-ind","status":"APPROVED",
1887
+ "epic_code":"EP-060","written_back_at":null,
1888
+ "epic":{"title":"Sangrada a mano","objective":"existir con dos espacios delante","layer":"business","depends_on":[]}}'
1889
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
1890
+ [ $rc -eq 7 ] && echo "OK epic-writeback ve la seccion sangrada y no reescribe" \
1891
+ || { echo "FAIL epic-writeback no reconocio el encabezado sangrado (rc $rc): $out"; fail=1; }
1892
+ python3 -c "
1893
+ import re
1894
+ t=open('$PROJ/docs/03-backlog/epicas.md').read()
1895
+ print(len(re.findall(r'^\s{0,3}#{1,6}\s+EP-060 ', t, re.M)))" | grep -qx 1 \
1896
+ && echo "OK la epica con encabezado sangrado no se duplica" \
1897
+ || { echo "FAIL el write-back duplico la epica con encabezado sangrado"; fail=1; }
1898
+
1899
+ # [Ronda final · A5] `--file` está acotado al carve-out de METODOLOGIA §9.2: el write-back
1900
+ # solo escribe bajo docs/03-backlog/. Fuera de ahí, rc 2 accionable y CERO escritura.
1901
+ ops_epic_ledger_upsert '{"client_event_id":"cid-scope","proposal_id":"prop-scope","status":"APPROVED",
1902
+ "epic_code":"EP-061","written_back_at":null,
1903
+ "epic":{"title":"Fuera del carve-out","objective":"no debe escribirse fuera","layer":"business","depends_on":[]}}'
1904
+ echo "# ajeno" > "$TMP/fuera.md"
1905
+ before_fuera="$(cat "$TMP/fuera.md")"
1906
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback --file "$TMP/fuera.md" 2>&1)"; rc=$?
1907
+ [ $rc -eq 2 ] && echo "OK epic-writeback --file fuera del carve-out da rc 2" \
1908
+ || { echo "FAIL --file fuera del carve-out (rc $rc): $out"; fail=1; }
1909
+ echo "$out" | grep -q '03-backlog' && echo "OK el rechazo de --file dice cual es la ruta permitida" \
1910
+ || { echo "FAIL el rechazo de --file no es accionable: $out"; fail=1; }
1911
+ [ "$(cat "$TMP/fuera.md")" = "$before_fuera" ] && echo "OK el fichero fuera del carve-out no se toco" \
1912
+ || { echo "FAIL se escribio un fichero fuera del carve-out"; fail=1; }
1913
+ # Ni siquiera por travesía: `docs/03-backlog/../../` sale del subárbol.
1914
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback --file "$PROJ/docs/03-backlog/../../fuera-travesia.md" 2>&1)"; rc=$?
1915
+ [ $rc -eq 2 ] && echo "OK epic-writeback rechaza la travesia con .." \
1916
+ || { echo "FAIL la travesia con .. escapo del carve-out (rc $rc): $out"; fail=1; }
1917
+ # Dentro del carve-out, un fichero distinto de epicas.md SÍ se acepta (el backlog puede estar partido).
1918
+ cat > "$PROJ/docs/03-backlog/epicas-extra.md" <<'MD'
1919
+ # Épicas (anexo)
1920
+ MD
1921
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback --file "$PROJ/docs/03-backlog/epicas-extra.md" 2>&1)"; rc=$?
1922
+ [ $rc -eq 0 ] && echo "$out" | grep -q 'EP-061' \
1923
+ && echo "OK epic-writeback acepta otra ruta DENTRO de docs/03-backlog/" \
1924
+ || { echo "FAIL --file dentro del carve-out (rc $rc): $out"; fail=1; }
1925
+ grep -q '^## EP-061 — Fuera del carve-out$' "$PROJ/docs/03-backlog/epicas-extra.md" \
1926
+ && echo "OK la epica se escribio en el fichero permitido" \
1927
+ || { echo "FAIL no se escribio en el fichero permitido"; fail=1; }
1928
+
1929
+ # Sin fichero de épicas: rc 4 accionable, no un traceback.
1930
+ rm -f "$PROJ/docs/03-backlog/epicas.md"
1931
+ ops_epic_ledger_upsert '{"client_event_id":"cid-w","written_back_at":null}'
1932
+ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
1933
+ [ $rc -eq 4 ] && echo "OK epic-writeback rc 4 sin fichero de epicas" \
1934
+ || { echo "FAIL epic-writeback sin fichero (rc $rc): $out"; fail=1; }
1935
+
1548
1936
  # ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
1549
1937
  GB="$ROOT/scripts/lib/graph-bundle.py"
1550
1938
  cat > "$TMP/epics.json" <<'JSON'
@@ -1593,4 +1981,190 @@ echo 'no-json' | python3 "$GB" > /dev/null 2> "$TMP/gb3.err"
1593
1981
  rc=$?
1594
1982
  [ "$rc" = 1 ] && ! grep -q "Traceback" "$TMP/gb3.err" && echo "OK graph-bundle con entrada ilegible -> rc 1 limpio" || { echo "FAIL entrada ilegible ($rc)"; fail=1; }
1595
1983
 
1984
+ # ══════════ graph-bundle.py --from-docs / --print-deps [issue #56] ══════════
1985
+ # El grafo llegaba al hub con `depends_on: []` (41/41 épicas en el piloto): la
1986
+ # extracción vivía en el LLM y el ejemplo de onboard anclaba en []. El extractor
1987
+ # es ahora DETERMINISTA y lee la prosa de `docs/03-backlog/epicas.md`.
1988
+ mkdir -p "$TMP/docs"
1989
+ cat > "$TMP/docs/epicas.md" <<'MD'
1990
+ # Backlog — épicas
1991
+
1992
+ **Dependencias entre épicas**: EP-999 (esto está FUERA de toda sección: se ignora)
1993
+
1994
+ ## EP-001 — Cimiento
1995
+
1996
+ **Depende de**: nada.
1997
+
1998
+ **Resumen**: base técnica.
1999
+
2000
+ ---
2001
+
2002
+ ## EP-002 — Cobros
2003
+
2004
+ **Anexo A**: Fase 3 · **Depende de**: **EP-001** (base de datos), EP-404 (no existe en el grafo)
2005
+
2006
+ **Resumen**: cobra.
2007
+
2008
+ ## EP-003 — Reportes
2009
+
2010
+ **Depende de**: EP-002 (pipeline de export),
2011
+ EP-001 (cimiento). Ninguna bloquea: todas archivadas.
2012
+
2013
+ **Resumen**: reporta.
2014
+
2015
+ ## EP-004 — Estructurada
2016
+
2017
+ depende_de: [EP-003]
2018
+
2019
+ **Depende de**: EP-001 en prosa — gana el campo estructurado.
2020
+
2021
+ ## EP-005 — Autorreferente
2022
+
2023
+ **Depende de**: EP-005 (a sí misma: se excluye), EP-001
2024
+ MD
2025
+
2026
+ # 1) --print-deps: extracción pura, determinista, inspeccionable.
2027
+ out="$(python3 "$GB" --print-deps "$TMP/docs/epicas.md" 2> "$TMP/gb4.err")"
2028
+ rc=$?
2029
+ # `**Depende de**: nada.` es una declaración, no una ausencia: entra con lista vacía.
2030
+ esperado='{"EP-001": [], "EP-002": ["EP-001", "EP-404"], "EP-003": ["EP-001", "EP-002"], "EP-004": ["EP-003"], "EP-005": ["EP-001"]}'
2031
+ [ "$rc" = 0 ] && [ "$(echo "$out" | python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin),sort_keys=True))')" = "$esperado" ] \
2032
+ && echo "OK --print-deps extrae de la prosa (multilínea, campo estructurado, sin autorreferencia)" \
2033
+ || { echo "FAIL --print-deps ($rc) -> $out"; fail=1; }
2034
+
2035
+ # 2) --from-docs puebla el grafo aunque el JSON del modelo traiga depends_on vacíos.
2036
+ cat > "$TMP/epics56.json" <<'JSON'
2037
+ {"project_ref":"acme","epics":[
2038
+ {"code":"EP-001","title":"Cimiento","layer":"foundational","depends_on":[]},
2039
+ {"code":"EP-002","title":"Cobros","layer":"business","depends_on":[]},
2040
+ {"code":"EP-003","title":"Reportes","layer":"business","depends_on":[]},
2041
+ {"code":"EP-004","title":"Estructurada","layer":"business","depends_on":["EP-002"]}
2042
+ ]}
2043
+ JSON
2044
+ out="$(python3 "$GB" --from-docs "$TMP/docs/epicas.md" < "$TMP/epics56.json" 2> "$TMP/gb5.err")"
2045
+ rc=$?
2046
+ [ "$rc" = 0 ] && [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(";".join(e["code"]+"="+",".join(e["depends_on"]) for e in d["epics"]))')" = "EP-001=;EP-002=EP-001,EP-404;EP-003=EP-001,EP-002;EP-004=EP-002,EP-003" ] \
2047
+ && echo "OK --from-docs puebla depends_on desde los docs (unión con lo declarado)" \
2048
+ || { echo "FAIL --from-docs ($rc) -> $out"; fail=1; }
2049
+ grep -q "EP-404" "$TMP/gb5.err" \
2050
+ && echo "OK --from-docs mantiene el aviso de dependencia inexistente" || { echo "FAIL sin aviso de dep inexistente con --from-docs"; fail=1; }
2051
+ grep -q "EP-005" "$TMP/gb5.err" \
2052
+ && echo "OK --from-docs avisa de la épica que está en docs pero no en el JSON" || { echo "FAIL sin aviso de épica ausente del JSON"; fail=1; }
2053
+ echo "$out" | grep -q '"depends_on_source"' \
2054
+ && echo "OK el bundle deja traza de la fuente de dependencias" || { echo "FAIL el bundle no declara depends_on_source"; fail=1; }
2055
+
2056
+ # 3) Los ciclos que vienen de la prosa se cazan igual (el aviso ya existía).
2057
+ cat > "$TMP/docs/ciclo.md" <<'MD'
2058
+ ## EP-010 — A
2059
+ **Depende de**: EP-011
2060
+ ## EP-011 — B
2061
+ **Depende de**: EP-010
2062
+ MD
2063
+ echo '{"epics":[{"code":"EP-010","title":"A","layer":"business"},{"code":"EP-011","title":"B","layer":"business"}]}' \
2064
+ | python3 "$GB" --from-docs "$TMP/docs/ciclo.md" > /dev/null 2> "$TMP/gb6.err"
2065
+ rc=$?
2066
+ [ "$rc" = 0 ] && grep -qi "ciclo" "$TMP/gb6.err" \
2067
+ && echo "OK --from-docs + ciclo de la prosa -> aviso, sin bloquear" || { echo "FAIL ciclo desde docs ($rc)"; fail=1; }
2068
+
2069
+ # 4) Ruta ilegible: rc 1 con mensaje, jamás traceback ni silencio (el fallo del piloto
2070
+ # fue precisamente un grafo vacío que no falló).
2071
+ echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/no-existe.md" > /dev/null 2> "$TMP/gb7.err"
2072
+ rc=$?
2073
+ [ "$rc" = 1 ] && ! grep -q "Traceback" "$TMP/gb7.err" \
2074
+ && echo "OK --from-docs con ruta ilegible -> rc 1 limpio" || { echo "FAIL ruta ilegible ($rc)"; fail=1; }
2075
+ # Flag sin argumento: mismo trato.
2076
+ echo '{"epics":[]}' | python3 "$GB" --from-docs > /dev/null 2> "$TMP/gb8.err"
2077
+ [ "$?" = 1 ] && ! grep -q "Traceback" "$TMP/gb8.err" \
2078
+ && echo "OK --from-docs sin argumento -> rc 1 limpio" || { echo "FAIL --from-docs sin argumento"; fail=1; }
2079
+
2080
+ # 4-bis) Sección presente pero SIN el campo: aviso distinto al de sección ausente
2081
+ # (un backlog que dibuja sus dependencias en ASCII no es un backlog sin épicas).
2082
+ printf '## EP-020 — Con seccion, sin campo\n\n**Resumen**: x.\n' > "$TMP/docs/sin-campo.md"
2083
+ echo '{"epics":[{"code":"EP-020","title":"X","layer":"business"},{"code":"EP-021","title":"Y","layer":"business"}]}' \
2084
+ | python3 "$GB" --from-docs "$TMP/docs/sin-campo.md" > /dev/null 2> "$TMP/gb10.err"
2085
+ grep -q "EP-020 tiene sección" "$TMP/gb10.err" && grep -q "EP-021 no aparece como sección" "$TMP/gb10.err" \
2086
+ && echo "OK distingue sección sin campo de épica ausente de los docs" || { echo "FAIL avisos de cobertura de docs"; fail=1; }
2087
+
2088
+ # 4-ter) Las dos banderas juntas son un error explícito (ignorar una en silencio es el
2089
+ # patrón que causó #56).
2090
+ echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/epicas.md" --print-deps "$TMP/docs/epicas.md" > /dev/null 2> "$TMP/gb11.err"
2091
+ [ "$?" = 1 ] && grep -qi "excluyentes" "$TMP/gb11.err" \
2092
+ && echo "OK --from-docs y --print-deps juntas -> rc 1 explícito" || { echo "FAIL banderas excluyentes"; fail=1; }
2093
+
2094
+ # 5) Sin sección de épica reconocible: aviso explícito (no silencio).
2095
+ printf 'texto sin encabezados de epica\n' > "$TMP/docs/vacio.md"
2096
+ echo '{"epics":[{"code":"EP-001","title":"X","layer":"business"}]}' \
2097
+ | python3 "$GB" --from-docs "$TMP/docs/vacio.md" > /dev/null 2> "$TMP/gb9.err"
2098
+ grep -qi "ninguna secci" "$TMP/gb9.err" \
2099
+ && echo "OK avisa cuando los docs no traen secciones de épica" || { echo "FAIL sin aviso de docs sin épicas"; fail=1; }
2100
+
2101
+ # ══════════════ slice-ops.sh: claim manda la version de grafo [#63] ══════════════
2102
+ # Stub PROPIO (puerto, routes-gv.json y calls-gv.jsonl): routes-cl.json/STUB_CL ya no existen
2103
+ # en este punto del fichero y mutarlos rompería aserciones aguas arriba (Ruling R3/R4).
2104
+ PORT_GV="$(freeport)"
2105
+ cat > "$TMP/routes-gv.json" <<JSON
2106
+ {"GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": {}, "nudges": [],
2107
+ "context": {"manifest_hash": "sha256:v2", "version": 9, "graph_version": 45}}},
2108
+ "POST /tasks/next": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 45}}}
2109
+ JSON
2110
+ : > "$TMP/calls-gv.jsonl"
2111
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GV" "$TMP/routes-gv.json" "$TMP/calls-gv.jsonl" &
2112
+ STUB_GV=$!
2113
+ STUBS="$STUBS $STUB_GV"
2114
+ sleep 0.3
2115
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GV\",\"project_token\":\"tok-gv\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\",\"manifest_hash\":\"sha256:v2\"}"
2116
+
2117
+ # --- [#63] claim manda la versión de grafo y trata el 409 como refresco ---
2118
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v2","version":9,"graph_version":42}}'
2119
+ : > "$TMP/calls-gv.jsonl"
2120
+ out="$(bash "$SO" claim 2>&1)"; rc=$?
2121
+ sent="$(python3 - "$TMP/calls-gv.jsonl" <<'PY'
2122
+ import json,sys
2123
+ for l in open(sys.argv[1]):
2124
+ d=json.loads(l)
2125
+ if d["path"]=="/tasks/next":
2126
+ print(json.loads(d["body"]).get("graph_version"))
2127
+ break
2128
+ PY
2129
+ )"
2130
+ [ "$sent" = 42 ] && echo "OK claim manda la version de grafo conocida" \
2131
+ || { echo "FAIL claim sin version de grafo ($sent)"; fail=1; }
2132
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
2133
+ [ "$n" = 2 ] && echo "OK un 409 stale_graph provoca refresco y UN reintento" \
2134
+ || { echo "FAIL reintentos del 409 stale_graph ($n)"; fail=1; }
2135
+ # [Ronda de arreglo 1, Minor 8] '45' y 'grafo' sueltos también los satisface el mensaje del
2136
+ # PRIMER 409 (_claim_once), que ya sale por stderr en el mismo $out combinado — no discriminan
2137
+ # nada salvo el rc. Se ancla a una frase exclusiva del SEGUNDO mensaje (cmd_claim).
2138
+ [ $rc -eq 6 ] && echo "$out" | grep -q '45' && echo "$out" | grep -qi 'incluso tras refrescar' \
2139
+ && echo "OK el segundo 409 nombra el desfase y la accion" \
2140
+ || { echo "FAIL mensaje del segundo 409 (rc $rc): $out"; fail=1; }
2141
+ runtime_graph_status_read | python3 -c "
2142
+ import json,sys; print(json.load(sys.stdin).get('server'))" | grep -qx 45 \
2143
+ && echo "OK el desfase queda registrado para status" || { echo "FAIL desfase sin registrar"; fail=1; }
2144
+
2145
+ # Un hub que no versiona: el cuerpo NO lleva graph_version y el claim funciona igual.
2146
+ python3 - "$TMP/routes-gv.json" <<'PY'
2147
+ import json,sys
2148
+ p=sys.argv[1]; d=json.load(open(p))
2149
+ d["POST /tasks/next"]={"status":204,"body":{}}
2150
+ d["GET /agent/context"]["body"]["context"].pop("graph_version",None)
2151
+ json.dump(d,open(p,"w"))
2152
+ PY
2153
+ runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v2","version":9}}'
2154
+ : > "$TMP/calls-gv.jsonl"
2155
+ bash "$SO" claim >/dev/null 2>&1; rc=$?
2156
+ has="$(python3 - "$TMP/calls-gv.jsonl" <<'PY'
2157
+ import json,sys
2158
+ for l in open(sys.argv[1]):
2159
+ d=json.loads(l)
2160
+ if d["path"]=="/tasks/next":
2161
+ print("SI" if "graph_version" in json.loads(d["body"]) else "NO")
2162
+ break
2163
+ PY
2164
+ )"
2165
+ [ "$has" = NO ] && [ $rc -eq 7 ] && echo "OK sin hub que versione, el claim no manda nada nuevo" \
2166
+ || { echo "FAIL compatibilidad hacia atras del claim (has=$has rc=$rc)"; fail=1; }
2167
+
2168
+ kill "$STUB_GV" 2>/dev/null; wait "$STUB_GV" 2>/dev/null
2169
+
1596
2170
  exit $fail