@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.
- 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 +34 -9
- 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/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/docs/commands.md +2 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/protocolo-cliente-runtime.md +64 -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/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 +456 -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'
|
|
@@ -1710,5 +2098,73 @@ echo '{"epics":[{"code":"EP-001","title":"X","layer":"business"}]}' \
|
|
|
1710
2098
|
grep -qi "ninguna secci" "$TMP/gb9.err" \
|
|
1711
2099
|
&& echo "OK avisa cuando los docs no traen secciones de épica" || { echo "FAIL sin aviso de docs sin épicas"; fail=1; }
|
|
1712
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
|
|
1713
2169
|
|
|
1714
2170
|
exit $fail
|
|
@@ -11,15 +11,27 @@ la épica; lo de abajo aplica a la épica y a **cada HU** que cubre (`hus[]`). L
|
|
|
11
11
|
- [ ] **Dependencias resueltas**: las épicas/HU de las que depende están archivadas (`history[]`) o explícitamente no bloquean. **Excepción dura — cimiento:** si la dependencia es **infraestructura fundacional** (autenticación, acceso a datos, arquitectura base, design-system/componentes base), la cláusula "explícitamente no bloquean" **NO aplica**: debe estar **construida y archivada** antes (ver criterio "Cimiento construido").
|
|
12
12
|
- [ ] **Cimiento construido (épicas de negocio)**: si esta épica es `layer: business`, todo el cimiento que arrastra (auth, acceso a datos, arquitectura base, design-system/componentes base) ya existe como épica(s) `layer: foundational` **archivada(s)** en `history[]`. Si arrastra cimiento no construido → **STOP**: extráelo a una épica fundacional previa y constrúyela primero. Las épicas fundacionales se priorizan **antes** que las de negocio.
|
|
13
13
|
- [ ] **Caparazón construido (solo greenfield — gate PROACTIVO)**: si `project_kind === "greenfield"`
|
|
14
|
-
y
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`foundation.
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
y la caparazón está **requerida**, ninguna épica `layer: business` entra a construcción mientras
|
|
15
|
+
la caparazón no esté **construida y archivada con su checklist evidenciada**. Las épicas
|
|
16
|
+
`layer: foundational` (la caparazón u otras fundacionales) sí pueden abrir. A diferencia de
|
|
17
|
+
"Cimiento construido" (reactivo: bloquea si *detecta* arrastre), este criterio bloquea
|
|
18
|
+
**siempre** en greenfield hasta que el cimiento exista archivado — no depende de detectar nada.
|
|
19
|
+
**Dónde se leen esos hechos depende del modo**, y no son los mismos:
|
|
20
|
+
- **`legacy`/`dual`** — `build-state.json`: `foundation.required`, `foundation.epic` (el
|
|
21
|
+
`EP-XXX` concreto) y `foundation.completed_at`. Si `foundation.epic` es `null` → **STOP**: la
|
|
22
|
+
épica caparazón no existe aún; se define en `/build:onboard` Fase 2c o en discovery.
|
|
23
|
+
- **`runtime`** — la proyección solo trae `foundation_done` (booleano), vía
|
|
24
|
+
`slice-ops.sh status` (línea `hechos:`). **Nadie fija `foundation.epic` en este modo**: la
|
|
25
|
+
Fase 2c propone la épica al hub sin código y la identidad la asigna el hub al aprobar
|
|
26
|
+
(issue #62). Así que aquí **no preguntes por `foundation.epic`** — no existe. Con
|
|
27
|
+
`foundation_done: false`, el estado real de la caparazón lo dice `slice-ops.sh epic-status`
|
|
28
|
+
(o `/build:epic --check`): si hay una propuesta `PROPOSED`, el STOP es **esperar la
|
|
29
|
+
aprobación**, no re-onboardear (repetir la Fase 2c propondría la misma épica dos veces); si
|
|
30
|
+
no hay ninguna propuesta, entonces sí → Fase 2c o discovery.
|
|
31
|
+
|
|
32
|
+
Brownfield o caparazón no requerida (`foundation.required: false` en `legacy`/`dual`;
|
|
33
|
+
`foundation_done: true` en `runtime`) → **N/A** (no bloquea).
|
|
34
|
+
Contrato: `references/foundation-contract.md`.
|
|
23
35
|
- [ ] **Tamaño acotado (gate de descomposición)**: si la épica supera el umbral —heurística por defecto **> 3 HU** ó **≥ 3 capas tocadas** (configurable por proyecto)— **no entra como slice único**: se descompone en `sub_slices[]` verificables construidos de a uno, con `journey_smoke` verde entre cada uno. El umbral es proporcional (no cuota rígida): una épica de 1 capa y pocas HU entra directa.
|
|
24
36
|
- [ ] **Cabe en el stack** del PRD §7 (no requiere tecnología fuera de `stack-allowlist.json`).
|
|
25
37
|
- [ ] **Cobertura arquitectónica (ADR)** *(opt-in, retrocompatible)*: si el proyecto adoptó la capa de
|
|
@@ -40,5 +40,9 @@ N/A y el arnés no pregunta ni exige nada (`foundation.required: false`).
|
|
|
40
40
|
- El arnés **propone** el borrador de la épica caparazón; **solo** lo escribe en
|
|
41
41
|
`docs/03-backlog/epicas.md` con aprobación humana explícita (carve-out acotado, ver
|
|
42
42
|
METODOLOGIA §9.2). Si el humano rechaza, STOP: la épica se crea en discovery (`/trycore:*`).
|
|
43
|
+
En modo `runtime`, esa aprobación humana en `/build:onboard` **propone al hub**
|
|
44
|
+
(`slice-ops.sh propose-epic`), no escribe: el fichero se escribe después, cuando el hub
|
|
45
|
+
también aprueba y asigna el `EP-XXX`, vía `/build:epic --check` (issue #62). En `legacy`/`dual`
|
|
46
|
+
la aprobación humana escribe directo, como arriba.
|
|
43
47
|
- La evidencia es de **ejecución**, nunca de inspección.
|
|
44
48
|
- Si contradice `METODOLOGIA.md`, gana la metodología.
|
package/state/README.md
CHANGED
|
@@ -141,3 +141,15 @@ El razonamiento vive en el modelo; el hook solo es un recordatorio determinista.
|
|
|
141
141
|
| `foundation.checklist[].evidence` | `build-orchestrator` (durante la construcción de la caparazón) | al construir la caparazón |
|
|
142
142
|
| `foundation.completed_at` | `dor-dod-gatekeeper` (al cerrar el DoD de la épica caparazón) | al archivar la caparazón |
|
|
143
143
|
| `design_source` (`source`, `confirmed`, `confirmed_by/at`, `notes`) | `building-a-slice` Fase 0-bis · `/build:onboard` Fase 3c · `prototyping-screens` (greenfield, solo tras aprobación humana de ≥1 pantalla; `confirmed` humano siempre) | una vez (proyecto) |
|
|
144
|
+
|
|
145
|
+
### Ficheros de estado aparte (no viven en `build-state.json`)
|
|
146
|
+
|
|
147
|
+
La tabla de arriba describe **campos** de `build-state.json`. Estos son **ficheros propios** de
|
|
148
|
+
`.claude/state/`: estado derivado del hub que solo existe en modo `runtime`, no forma parte de la
|
|
149
|
+
máquina del pipeline y por eso no entra en el schema del estado. `trycore-build init` los siembra
|
|
150
|
+
en `.gitignore`.
|
|
151
|
+
|
|
152
|
+
| Fichero | Lo escribe | Cadencia |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `epic-proposals.json` — ledger de propuestas de épica (`client_event_id`, `proposal_id`, `status`, `epic_code`, `draft`, `written_back_at`) | `slice-ops.sh propose-epic` / `epic-status` / `epic-writeback` | por propuesta de épica |
|
|
155
|
+
| `graph-status.json` — marcador de desfase de grafo (`local`, `server`, `seen_at`): lo deja un `409 stale_graph` del hub y **su existencia es la señal** (el fichero se borra en cuanto la acción vuelve a pasar). Es lo que hace **visible** el desfase en `slice-ops.sh status` y `trycore-build status`; sin él el 409 se resolvería solo y nadie sabría que ocurrió. `local: null` = la petición salió sin versión estampada — nunca `0`, que sería una versión real | `runtime_graph_note_stale` / `runtime_graph_clear_stale` (`hooks/build/lib/runtime-client.sh`), desde `slice-ops.sh claim` y desde el despacho del carril directo | por rechazo `409 stale_graph` |
|