@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.
- package/.claude-plugin/plugin.json +1 -1
- package/METODOLOGIA.md +6 -1
- package/README.md +1 -0
- package/VERSION +1 -1
- package/agents/build/dor-dod-gatekeeper.md +10 -3
- package/commands/build/epic.md +109 -0
- package/commands/build/onboard.md +59 -17
- package/commands/build/slice.md +4 -2
- package/commands/build/work.md +6 -2
- package/config/build-config.template.json +2 -1
- package/dist/commands/init.js +13 -0
- package/dist/commands/status.js +13 -1
- package/dist/lib/normalize.js +11 -0
- package/dist/lib/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/dist/lib/state-bundle.js +15 -1
- package/docs/commands.md +2 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/protocolo-cliente-runtime.md +90 -0
- package/hooks/build/design-source-guard.sh +12 -1
- package/hooks/build/heartbeat.sh +64 -4
- package/hooks/build/lib/agent-context.sh +16 -7
- package/hooks/build/lib/config.sh +25 -1
- package/hooks/build/lib/runtime-client.sh +540 -9
- package/hooks/build/lib/runtime-ops.sh +108 -0
- package/hooks/build/scaffold-guard.sh +14 -1
- package/hooks/build/slice-ops.sh +498 -16
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +165 -1
- package/scripts/tests/test-config.sh +41 -0
- package/scripts/tests/test-hooks-runtime.sh +198 -3
- package/scripts/tests/test-install.sh +23 -0
- package/scripts/tests/test-runtime-client.sh +500 -0
- package/scripts/tests/test-skill-ops.sh +574 -0
- package/skills/building-a-slice/references/dor.md +21 -9
- package/skills/building-a-slice/references/foundation-contract.md +4 -0
- 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
|