@trycore/spec-build-harness 0.14.2 → 0.16.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/GOVERNANCE.md +1 -1
- package/INSTALL.md +1 -1
- package/METODOLOGIA.md +15 -4
- package/README.md +43 -6
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +3 -0
- package/commands/build/front.md +8 -4
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +11 -2
- package/docs/getting-started.md +1 -1
- package/docs/hooks.md +4 -2
- package/docs/runtime/guia-modo-dual-y-migracion.md +79 -3
- package/docs/runtime/protocolo-cliente-runtime.md +66 -7
- package/hooks/build/lib/config.sh +56 -8
- package/hooks/build/lib/harness-meta.sh +72 -0
- package/hooks/build/lib/runtime-client.sh +120 -14
- package/hooks/build/lib/runtime-ops.sh +160 -27
- package/hooks/build/release-ops.sh +38 -12
- package/hooks/build/session-start.sh +5 -61
- package/hooks/build/slice-ops.sh +615 -41
- package/package.json +1 -1
- package/scripts/lib/front-plan.py +55 -6
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/test-config.sh +40 -0
- package/scripts/tests/test-front-plan.sh +63 -0
- package/scripts/tests/test-install.sh +2 -1
- package/scripts/tests/test-runtime-client.sh +49 -3
- package/scripts/tests/test-skill-ops.sh +516 -27
- package/scripts/tests/test-worktree-identity.sh +207 -0
- package/skills/managing-parallel-front/SKILL.md +35 -8
|
@@ -38,6 +38,13 @@ source "$ROOT/hooks/build/lib/runtime-ops.sh" 2>/dev/null || { echo "FAIL no exi
|
|
|
38
38
|
[ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/agent/epic-proposals/prop-7" ] \
|
|
39
39
|
&& echo "OK ops_path_epic_proposal" || { echo "FAIL ops_path_epic_proposal"; fail=1; }
|
|
40
40
|
|
|
41
|
+
# [EP-OR-17] Re-sync del grafo: mismo carril `/agent/`. Sin él se cae en la superficie HUMANA
|
|
42
|
+
# (sesión + PDP), que a un bearer de agente le responde 403 auditado.
|
|
43
|
+
[ "$(ops_path_graph_sync_proposals p-1)" = "/projects/p-1/agent/graph-sync-proposals" ] \
|
|
44
|
+
&& echo "OK ops_path_graph_sync_proposals" || { echo "FAIL ops_path_graph_sync_proposals"; fail=1; }
|
|
45
|
+
[ "$(ops_path_graph_sync_proposal p-1 gs-7)" = "/projects/p-1/agent/graph-sync-proposals/gs-7" ] \
|
|
46
|
+
&& echo "OK ops_path_graph_sync_proposal" || { echo "FAIL ops_path_graph_sync_proposal"; fail=1; }
|
|
47
|
+
|
|
41
48
|
# [#62] Ledger: upsert por client_event_id, sin duplicar, conservando lo no mencionado.
|
|
42
49
|
rm -f "$(ops_epic_ledger_path)"
|
|
43
50
|
ops_epic_ledger_upsert '{"client_event_id":"cid-1","status":"QUEUED","draft":{"title":"Notificaciones"}}'
|
|
@@ -144,7 +151,7 @@ fi
|
|
|
144
151
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
145
152
|
|
|
146
153
|
# --- ops_mirror: sólo tiene sentido con project_id; nunca lanza sin él ---
|
|
147
|
-
ops_mirror
|
|
154
|
+
ops_mirror gate_verdict "$TMP/payload.json" EP-003; rc=$?
|
|
148
155
|
[ "$rc" -ne 0 ] && echo "OK ops_mirror sin credenciales -> rc != 0" || { echo "FAIL ops_mirror sin credenciales"; fail=1; }
|
|
149
156
|
|
|
150
157
|
# ══════════════ slice-ops.sh: mode / claim / next-step [Task 2] ══════════════
|
|
@@ -183,6 +190,56 @@ STUBS="$STUBS $!"
|
|
|
183
190
|
sleep 0.3
|
|
184
191
|
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\",\"project_token\":\"tok-c\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\",\"manifest_hash\":\"sha256:v1\"}"
|
|
185
192
|
|
|
193
|
+
# ── [EP-OR-17] Ledger y cid del re-sync del grafo ────────────────────────────────────
|
|
194
|
+
# Va aquí, y no arriba con el ledger de épicas, porque `ops_graph_sync_cid` necesita la
|
|
195
|
+
# `agent_key` de las credenciales que la línea anterior acaba de fijar.
|
|
196
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
197
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:0","status":"QUEUED","epics":41}'
|
|
198
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:0","status":"PROPOSED","proposal_id":"gs-7"}'
|
|
199
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
200
|
+
import json,sys
|
|
201
|
+
ps=json.load(sys.stdin)['proposals']
|
|
202
|
+
e=ps[0]
|
|
203
|
+
print('SI' if len(ps)==1 and e['status']=='PROPOSED' and e['proposal_id']=='gs-7' and e['epics']==41 else 'NO')" \
|
|
204
|
+
| grep -q SI && echo "OK el ledger de graph-sync hace upsert sin perder campos" \
|
|
205
|
+
|| { echo "FAIL upsert del ledger de graph-sync"; fail=1; }
|
|
206
|
+
case "$(ops_graph_sync_ledger_path)" in
|
|
207
|
+
*/.claude/state/graph-sync-proposals.json) echo "OK el ledger de graph-sync vive en su propio fichero" ;;
|
|
208
|
+
*) echo "FAIL ruta del ledger de graph-sync"; fail=1 ;;
|
|
209
|
+
esac
|
|
210
|
+
|
|
211
|
+
# El cid es DETERMINISTA y avanza con el ledger: el reintento reusa el mismo, una propuesta
|
|
212
|
+
# nueva toma el siguiente.
|
|
213
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
214
|
+
cid_a="$(ops_graph_sync_cid)"; cid_b="$(ops_graph_sync_cid)"
|
|
215
|
+
[ "$cid_a" = "$cid_b" ] && echo "OK ops_graph_sync_cid es estable mientras no se registre nada" \
|
|
216
|
+
|| { echo "FAIL cid inestable ($cid_a / $cid_b)"; fail=1; }
|
|
217
|
+
[ "$cid_a" = "ak-1:graph-sync:1" ] && echo "OK el cid sigue el patrón <agent_key>:graph-sync:<n>" \
|
|
218
|
+
|| { echo "FAIL patrón del cid ($cid_a)"; fail=1; }
|
|
219
|
+
ops_graph_sync_ledger_upsert "{\"client_event_id\":\"$cid_a\",\"status\":\"PROPOSED\"}"
|
|
220
|
+
[ "$(ops_graph_sync_cid)" = "ak-1:graph-sync:2" ] \
|
|
221
|
+
&& echo "OK el cid avanza cuando el ledger registra una propuesta" \
|
|
222
|
+
|| { echo "FAIL el cid no avanza ($(ops_graph_sync_cid))"; fail=1; }
|
|
223
|
+
[ "${#cid_a}" -le 64 ] && echo "OK el cid cabe en los 64 caracteres del contrato" \
|
|
224
|
+
|| { echo "FAIL cid demasiado largo (${#cid_a})"; fail=1; }
|
|
225
|
+
# Una agent_key larga no puede desbordar el contrato: se sustituye por su sha256 truncado.
|
|
226
|
+
ak_prev="$(runtime_field agent_key)"
|
|
227
|
+
runtime_credentials_merge "{\"agent_key\":\"$(python3 -c 'print("a"*120)')\"}"
|
|
228
|
+
cid_l="$(ops_graph_sync_cid)"
|
|
229
|
+
[ "${#cid_l}" -le 64 ] && [ -n "$cid_l" ] \
|
|
230
|
+
&& echo "OK una agent_key larga no desborda el cid (${#cid_l})" \
|
|
231
|
+
|| { echo "FAIL cid desbordado con agent_key larga (${#cid_l})"; fail=1; }
|
|
232
|
+
runtime_credentials_merge "{\"agent_key\":\"$ak_prev\"}"
|
|
233
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
234
|
+
|
|
235
|
+
# El ledger de épicas no se toca: misma conducta tras extraer la base común.
|
|
236
|
+
rm -f "$(ops_epic_ledger_path)"
|
|
237
|
+
ops_epic_ledger_upsert '{"client_event_id":"cid-9","status":"QUEUED"}'
|
|
238
|
+
ops_epic_ledger_read | grep -q '"cid-9"' \
|
|
239
|
+
&& echo "OK el ledger de épicas sobrevive a la extracción de la base común" \
|
|
240
|
+
|| { echo "FAIL regresión del ledger de épicas"; fail=1; }
|
|
241
|
+
rm -f "$(ops_epic_ledger_path)"
|
|
242
|
+
|
|
186
243
|
# (#39) `claim --epic` NO existe en el protocolo del hub: `TasksNextIn` no declara
|
|
187
244
|
# epic_code (pydantic lo ignoraba y la cola repartía OTRA épica con claim "exitoso").
|
|
188
245
|
# Debe fallar EXPLÍCITO antes de tocar la red.
|
|
@@ -927,16 +984,18 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
|
|
|
927
984
|
[ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase en dual -> rc 0 sin encolar" || { echo "FAIL phase dual (rc=$rc n=$n)"; fail=1; }
|
|
928
985
|
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
929
986
|
import json,sys
|
|
987
|
+
# [EP-OR-17] El shape es MirrorTransitionsIn: la épica va en el SOBRE (`epic_code`), no como
|
|
988
|
+
# `slice_ref` dentro del payload — los schemas del catálogo son extra="forbid".
|
|
930
989
|
tos=[]
|
|
931
990
|
for line in open(sys.argv[1]):
|
|
932
991
|
c=json.loads(line)
|
|
933
992
|
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
993
|
+
for t in json.loads(c["body"])["transitions"]:
|
|
994
|
+
if t["event_type"]=="phase_advanced":
|
|
995
|
+
tos.append((t["payload"].get("to"), t["epic_code"]))
|
|
937
996
|
print("SI" if tos==[("green","EP-009")] else "NO:"+repr(tos))
|
|
938
997
|
PYX
|
|
939
|
-
)" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con
|
|
998
|
+
)" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con el epic_code en el sobre" \
|
|
940
999
|
|| { echo "FAIL espejo de phase en dual"; fail=1; }
|
|
941
1000
|
|
|
942
1001
|
# dual: archive también hace catch-up por el espejo (cadena red→…→pr antes del archivado).
|
|
@@ -950,12 +1009,14 @@ kinds=[]
|
|
|
950
1009
|
for line in open(sys.argv[1]):
|
|
951
1010
|
c=json.loads(line)
|
|
952
1011
|
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1012
|
+
for t in json.loads(c["body"])["transitions"]:
|
|
1013
|
+
kinds.append((t["event_type"], t["payload"].get("to")))
|
|
1014
|
+
# [EP-OR-17] `slice_archived` NO está en TIPOS_DE_ESPEJO del hub: la cadena de fases sí se
|
|
1015
|
+
# espeja, y el archivado se corta en local (rc 8) en vez de viajar para volver rechazado.
|
|
1016
|
+
esperado=[("phase_advanced",t) for t in ["green","refactor","smoke","api","data","dod","pr"]]
|
|
956
1017
|
print("SI" if kinds==esperado else "NO:"+repr(kinds))
|
|
957
1018
|
PYX
|
|
958
|
-
)" = SI ] && echo "OK archive en dual espeja la cadena
|
|
1019
|
+
)" = SI ] && echo "OK archive en dual espeja la cadena de fases y no el slice_archived no espejable" \
|
|
959
1020
|
|| { echo "FAIL cadena del archive dual"; fail=1; }
|
|
960
1021
|
|
|
961
1022
|
# Restaura la ruta y la caché al estado que esperan los bloques siguientes (sl-7, fase tdd).
|
|
@@ -982,12 +1043,16 @@ for line in open(sys.argv[1]):
|
|
|
982
1043
|
c=json.loads(line)
|
|
983
1044
|
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
984
1045
|
b=json.loads(c["body"])
|
|
985
|
-
|
|
986
|
-
#
|
|
987
|
-
|
|
1046
|
+
t=b["transitions"][0]
|
|
1047
|
+
# [#44] el espejo usa el nombre del catálogo (gate_verdict, no verdict_reported).
|
|
1048
|
+
# [EP-OR-17] La identidad local viaja en el SOBRE (`epic_code`); el `origin` ya no se
|
|
1049
|
+
# manda — la marca de origen-espejo la estampa el SERVIDOR, y lo que dijera el cliente
|
|
1050
|
+
# no elegiría el origen de todos modos.
|
|
1051
|
+
ok = (list(b.keys())==["transitions"] and t["event_type"]=="gate_verdict"
|
|
1052
|
+
and t["epic_code"]=="EP-009" and "slice_ref" not in t["payload"])
|
|
988
1053
|
print("SI" if ok else "NO")
|
|
989
1054
|
PYX
|
|
990
|
-
)" = SI ] && echo "OK dual espeja con la identidad fichero-primaria" || { echo "FAIL espejo dual"; fail=1; }
|
|
1055
|
+
)" = SI ] && echo "OK dual espeja con la identidad fichero-primaria en el sobre" || { echo "FAIL espejo dual"; fail=1; }
|
|
991
1056
|
[ ! -s "$PROJ/.claude/state/outbox" ] || true
|
|
992
1057
|
grep -q '"path": "/slices/' "$TMP/calls-ga.jsonl" && { echo "FAIL dual llamó a la superficie primaria"; fail=1; } || echo "OK dual no llama a /slices (el fichero decide)"
|
|
993
1058
|
|
|
@@ -999,12 +1064,15 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
|
999
1064
|
|
|
1000
1065
|
# ══════════════ hooks/build/dual-compare.sh [Task E] ══════════════
|
|
1001
1066
|
# build-state.json de arriba sigue en pie: EP-009, phase tdd, gates {dor:true}.
|
|
1002
|
-
# En dual, escalate NO encola:
|
|
1003
|
-
#
|
|
1067
|
+
# En dual, escalate NO encola: intenta el espejo (el fichero es primario).
|
|
1068
|
+
# [EP-OR-17] `slice_escalated` existe en el catálogo del hub pero NO es espejable
|
|
1069
|
+
# (`TIPOS_DE_ESPEJO` solo cubre apertura, fases, gates, wiring, checkpoints, handoff y notas de
|
|
1070
|
+
# progreso). Antes la escalada viajaba para volver rechazada; ahora se corta en local. La
|
|
1071
|
+
# discrepancia sigue siendo VISIBLE donde el humano la lee —el stderr del hook Stop— y el hueco
|
|
1072
|
+
# de registro en el hub queda documentado como pendiente hub-side, no disimulado aquí.
|
|
1004
1073
|
DC="$ROOT/hooks/build/dual-compare.sh"
|
|
1005
1074
|
mirror_escalated() {
|
|
1006
|
-
# SI/NO: ¿
|
|
1007
|
-
# [#44] la escalada usa el nombre del catálogo v2 (slice_escalated) también en el espejo.
|
|
1075
|
+
# SI/NO: ¿viajó al espejo un `slice_escalated`? Debe ser NO: el hub no lo acepta.
|
|
1008
1076
|
python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
1009
1077
|
import json,sys
|
|
1010
1078
|
found=False
|
|
@@ -1014,9 +1082,9 @@ for line in open(sys.argv[1]):
|
|
|
1014
1082
|
except Exception:
|
|
1015
1083
|
continue
|
|
1016
1084
|
if c.get("path")=="/projects/p-1/mirror/transitions":
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1085
|
+
for t in json.loads(c["body"]).get("transitions") or []:
|
|
1086
|
+
if t.get("event_type")=="slice_escalated":
|
|
1087
|
+
found=True
|
|
1020
1088
|
print("SI" if found else "NO")
|
|
1021
1089
|
PYX
|
|
1022
1090
|
}
|
|
@@ -1053,9 +1121,10 @@ json.dump(d,open(p,"w"))
|
|
|
1053
1121
|
PYX
|
|
1054
1122
|
: > "$TMP/calls-ga.jsonl"
|
|
1055
1123
|
rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
|
|
1056
|
-
[ "$rc" = 0 ] && [ "$(mirror_escalated)" =
|
|
1124
|
+
[ "$rc" = 0 ] && [ "$(mirror_escalated)" = NO ] && echo "$err" | grep -qi "discrepancia" \
|
|
1057
1125
|
&& echo "$err" | grep -q "EP-009" && echo "$err" | grep -q "EP-999" \
|
|
1058
|
-
&& echo "OK dual-compare con épica distinta
|
|
1126
|
+
&& echo "OK dual-compare con épica distinta avisa con el detalle de ambos lados, sin mandar al espejo un tipo que el hub no acepta" \
|
|
1127
|
+
|| { echo "FAIL dual-compare épica distinta (rc=$rc err=$err)"; fail=1; }
|
|
1059
1128
|
|
|
1060
1129
|
# dual, gate DISTINTO (mismo epic/fase): también cuenta como discrepancia.
|
|
1061
1130
|
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
@@ -1065,8 +1134,10 @@ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9",
|
|
|
1065
1134
|
json.dump(d,open(p,"w"))
|
|
1066
1135
|
PYX
|
|
1067
1136
|
: > "$TMP/calls-ga.jsonl"
|
|
1068
|
-
rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null
|
|
1069
|
-
|
|
1137
|
+
rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
|
|
1138
|
+
echo "$err" | grep -qi "discrepancia" && [ "$(mirror_escalated)" = NO ] \
|
|
1139
|
+
&& echo "OK dual-compare detecta gate distinto (dor: true vs false)" \
|
|
1140
|
+
|| { echo "FAIL dual-compare gate distinto (rc=$rc err=$err)"; fail=1; }
|
|
1070
1141
|
|
|
1071
1142
|
# dual, runtime SIN slice activo (el fichero sí tiene): también es discrepancia.
|
|
1072
1143
|
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
@@ -1076,8 +1147,10 @@ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":Tr
|
|
|
1076
1147
|
json.dump(d,open(p,"w"))
|
|
1077
1148
|
PYX
|
|
1078
1149
|
: > "$TMP/calls-ga.jsonl"
|
|
1079
|
-
rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null
|
|
1080
|
-
|
|
1150
|
+
rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
|
|
1151
|
+
echo "$err" | grep -qi "discrepancia" && [ "$(mirror_escalated)" = NO ] \
|
|
1152
|
+
&& echo "OK dual-compare detecta slice ausente en el runtime" \
|
|
1153
|
+
|| { echo "FAIL dual-compare slice ausente en runtime (rc=$rc err=$err)"; fail=1; }
|
|
1081
1154
|
|
|
1082
1155
|
# fail-open: espejo caído (runtime inalcanzable) -> nunca lanza, nunca aborta (rc 0 siempre).
|
|
1083
1156
|
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
@@ -1999,6 +2072,317 @@ out="$(cd "$PROJ" && bash "$SO" epic-writeback 2>&1)"; rc=$?
|
|
|
1999
2072
|
[ $rc -eq 4 ] && echo "OK epic-writeback rc 4 sin fichero de epicas" \
|
|
2000
2073
|
|| { echo "FAIL epic-writeback sin fichero (rc $rc): $out"; fail=1; }
|
|
2001
2074
|
|
|
2075
|
+
# ══════════════ slice-ops.sh: graph-sync / graph-sync-status [EP-OR-17] ══════════════
|
|
2076
|
+
# Stub PROPIO (puerto, routes-gs.json y calls-gs.jsonl): mutar los de arriba rompería
|
|
2077
|
+
# aserciones aguas arriba, y este bloque reescribe la ruta del POST varias veces.
|
|
2078
|
+
PORT_GS="$(freeport)"
|
|
2079
|
+
cat > "$TMP/routes-gs.json" <<'JSON'
|
|
2080
|
+
{"POST /projects/p-1/agent/graph-sync-proposals": {"status": 201, "body": {"id": "gs-7", "project_id": "p-1", "status": "PROPOSED", "graph_version": 4, "bundle": {"epics": []}, "delta": {"epics_added": [{"code": "EP-002", "title": "Cobros", "layer": "BUSINESS", "stories": ["HU-011"]}], "epics_changed": [], "stories_added": [], "stories_changed": []}, "proposed_by": "ak-1", "proposed_at": "2026-09-07T10:00:00Z"}},
|
|
2081
|
+
"GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": null, "nudges": [], "context": {"manifest_hash": "sha256:v1", "version": 4, "graph_version": 4}}},
|
|
2082
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
2083
|
+
JSON
|
|
2084
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2085
|
+
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GS" "$TMP/routes-gs.json" "$TMP/calls-gs.jsonl" &
|
|
2086
|
+
STUB_GS=$!
|
|
2087
|
+
STUBS="$STUBS $STUB_GS"
|
|
2088
|
+
sleep 0.3
|
|
2089
|
+
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GS\",\"project_token\":\"tok-gs\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\"}"
|
|
2090
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v1","version":4,"graph_version":4}}'
|
|
2091
|
+
|
|
2092
|
+
cat > "$TMP/gs-epics.json" <<'JSON'
|
|
2093
|
+
{"epics":[{"code":"EP-001","title":"Cimiento","layer":"foundational","stories":[]},
|
|
2094
|
+
{"code":"EP-002","title":"Cobros","layer":"business","depends_on":["EP-001"],
|
|
2095
|
+
"stories":[{"id":"HU-011","title":"Registrar cobro"}]}]}
|
|
2096
|
+
JSON
|
|
2097
|
+
|
|
2098
|
+
# legacy y dual: el grafo vive en los documentos y entra al hub por el import de admin.
|
|
2099
|
+
rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2100
|
+
[ "$rc" = 3 ] && echo "OK graph-sync en legacy -> rc 3" || { echo "FAIL graph-sync legacy rc ($rc): $out"; fail=1; }
|
|
2101
|
+
|
|
2102
|
+
# --dry-run: imprime el bundle proyectado y NO abre socket. Sirve en cualquier modo.
|
|
2103
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2104
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" --dry-run 2>/dev/null)" || rc=$?
|
|
2105
|
+
[ "$rc" = 0 ] && echo "OK graph-sync --dry-run -> rc 0" || { echo "FAIL dry-run rc ($rc)"; fail=1; }
|
|
2106
|
+
printf '%s' "$out" | python3 -c "
|
|
2107
|
+
import json,sys
|
|
2108
|
+
d=json.load(sys.stdin)
|
|
2109
|
+
print('SI' if list(d.keys())==['epics'] and len(d['epics'])==2 else 'NO')" | grep -q SI \
|
|
2110
|
+
&& echo "OK --dry-run enseña el bundle proyectado" || { echo "FAIL dry-run sin bundle ($out)"; fail=1; }
|
|
2111
|
+
grep -q 'graph-sync-proposals' "$TMP/calls-gs.jsonl" && { echo "FAIL --dry-run tocó la red"; fail=1; } \
|
|
2112
|
+
|| echo "OK --dry-run no abre socket"
|
|
2113
|
+
|
|
2114
|
+
# Bundle que no cumple el contrato: se corta en LOCAL, sin abrir socket.
|
|
2115
|
+
echo '{"epics":[{"code":"EP-009","title":"Sin capa"}]}' > "$TMP/gs-malo.json"
|
|
2116
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2117
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-malo.json" 2>&1)" || rc=$?
|
|
2118
|
+
[ "$rc" = 2 ] && echo "OK bundle inválido -> rc 2 (uso)" || { echo "FAIL bundle inválido rc ($rc): $out"; fail=1; }
|
|
2119
|
+
grep -q 'graph-sync-proposals' "$TMP/calls-gs.jsonl" && { echo "FAIL bundle inválido tocó la red"; fail=1; } \
|
|
2120
|
+
|| echo "OK un bundle inválido no abre socket"
|
|
2121
|
+
|
|
2122
|
+
# 201: PROPOSED en el ledger con el id del hub; el cuerpo cumple GraphSyncProposeIn.
|
|
2123
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2124
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2125
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2126
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2127
|
+
[ "$rc" = 0 ] && echo "OK graph-sync 201 -> rc 0" || { echo "FAIL graph-sync 201 rc ($rc): $out"; fail=1; }
|
|
2128
|
+
echo "$out" | grep -q "gs-7" && echo "OK graph-sync enseña el id de la propuesta" \
|
|
2129
|
+
|| { echo "FAIL id ausente ($out)"; fail=1; }
|
|
2130
|
+
echo "$out" | grep -qi "aprueb" && echo "OK graph-sync dice que falta la aprobación humana" \
|
|
2131
|
+
|| { echo "FAIL sin mención de la aprobación ($out)"; fail=1; }
|
|
2132
|
+
echo "$out" | grep -q "EP-002" && echo "OK graph-sync resume el delta que devuelve el hub" \
|
|
2133
|
+
|| { echo "FAIL delta sin resumir ($out)"; fail=1; }
|
|
2134
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2135
|
+
&& echo "OK el cuerpo enviado cumple GraphSyncProposeIn" || { echo "FAIL cuerpo de graph-sync"; fail=1; }
|
|
2136
|
+
import json,sys
|
|
2137
|
+
env=None
|
|
2138
|
+
for line in open(sys.argv[1]):
|
|
2139
|
+
c=json.loads(line)
|
|
2140
|
+
if c["path"].endswith("/agent/graph-sync-proposals") and c["method"]=="POST":
|
|
2141
|
+
env=json.loads(c["body"])
|
|
2142
|
+
ok = (env is not None
|
|
2143
|
+
and set(env.keys()) <= {"bundle","client_event_id","graph_version"}
|
|
2144
|
+
and list(env["bundle"].keys())==["epics"]
|
|
2145
|
+
and len(env["bundle"]["epics"])==2
|
|
2146
|
+
and env["bundle"]["epics"][1]["layer"]=="BUSINESS"
|
|
2147
|
+
and env["client_event_id"].endswith(":graph-sync:1")
|
|
2148
|
+
and env["graph_version"]==4)
|
|
2149
|
+
print("SI" if ok else "NO %s" % json.dumps(env))
|
|
2150
|
+
PYX
|
|
2151
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
2152
|
+
import json,sys
|
|
2153
|
+
ps=json.load(sys.stdin)['proposals']
|
|
2154
|
+
print('SI' if len(ps)==1 and ps[0]['status']=='PROPOSED' and ps[0]['proposal_id']=='gs-7' else 'NO')" \
|
|
2155
|
+
| grep -q SI && echo "OK el ledger registra la propuesta PROPOSED" \
|
|
2156
|
+
|| { echo "FAIL ledger tras 201"; fail=1; }
|
|
2157
|
+
|
|
2158
|
+
# 200 empty_delta: el grafo ya está al día. No es un error y NO deja entrada viva en el ledger.
|
|
2159
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2160
|
+
import json,sys
|
|
2161
|
+
d=json.load(open(sys.argv[1]))
|
|
2162
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":200,"body":{"empty_delta":True,"message":"El bundle coincide con el grafo vigente: no hay cambios que proponer"}}
|
|
2163
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2164
|
+
PYX
|
|
2165
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2166
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2167
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2168
|
+
[ "$rc" = 0 ] && echo "OK graph-sync empty_delta -> rc 0" || { echo "FAIL empty_delta rc ($rc): $out"; fail=1; }
|
|
2169
|
+
echo "$out" | grep -qi "al día" && echo "OK empty_delta se anuncia como grafo al día" \
|
|
2170
|
+
|| { echo "FAIL mensaje de empty_delta ($out)"; fail=1; }
|
|
2171
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
2172
|
+
import json,sys
|
|
2173
|
+
ps=json.load(sys.stdin)['proposals']
|
|
2174
|
+
print('SI' if all(p.get('status')!='PROPOSED' for p in ps) else 'NO')" | grep -q SI \
|
|
2175
|
+
&& echo "OK empty_delta no deja una propuesta viva en el ledger" \
|
|
2176
|
+
|| { echo "FAIL empty_delta dejó una propuesta viva"; fail=1; }
|
|
2177
|
+
|
|
2178
|
+
# 422: bundle inválido para el hub -> rechazo, con el motivo del hub a la vista.
|
|
2179
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2180
|
+
import json,sys
|
|
2181
|
+
d=json.load(open(sys.argv[1]))
|
|
2182
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":422,"body":{"detail":"la épica EP-002 depende de EP-999, que no existe"}}
|
|
2183
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2184
|
+
PYX
|
|
2185
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2186
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2187
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2188
|
+
[ "$rc" = 6 ] && echo "OK graph-sync 422 -> rc 6" || { echo "FAIL 422 rc ($rc): $out"; fail=1; }
|
|
2189
|
+
echo "$out" | grep -q "EP-999" && echo "OK el 422 muestra el motivo del hub" \
|
|
2190
|
+
|| { echo "FAIL motivo del 422 ($out)"; fail=1; }
|
|
2191
|
+
|
|
2192
|
+
# 409 graph_version_stale: se refresca el contexto y se reintenta UNA vez con el MISMO cid.
|
|
2193
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2194
|
+
import json,sys
|
|
2195
|
+
d=json.load(open(sys.argv[1]))
|
|
2196
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":409,"body":{"detail":{"error":"graph_version_stale","message":"el grafo avanzo","graph_version":9}}}
|
|
2197
|
+
d["GET /agent/context"]["body"]["context"]["graph_version"]=9
|
|
2198
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2199
|
+
PYX
|
|
2200
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2201
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2202
|
+
runtime_graph_clear_stale
|
|
2203
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","active_slice":null,"nudges":[],"context":{"manifest_hash":"sha256:v1","version":4,"graph_version":4}}'
|
|
2204
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2205
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2206
|
+
n="$(grep -c '"path": "/projects/p-1/agent/graph-sync-proposals"' "$TMP/calls-gs.jsonl" | tr -d ' ')"
|
|
2207
|
+
[ "$n" = 2 ] && echo "OK el 409 stale provoca UN reintento (2 envíos)" \
|
|
2208
|
+
|| { echo "FAIL reintentos del 409 ($n): $out"; fail=1; }
|
|
2209
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2210
|
+
&& echo "OK el reintento reusa el MISMO client_event_id y re-estampa la versión" \
|
|
2211
|
+
|| { echo "FAIL identidad del reintento"; fail=1; }
|
|
2212
|
+
import json,sys
|
|
2213
|
+
envs=[json.loads(json.loads(l)["body"]) for l in open(sys.argv[1])
|
|
2214
|
+
if json.loads(l)["path"].endswith("/agent/graph-sync-proposals")]
|
|
2215
|
+
ok = (len(envs)==2 and envs[0]["client_event_id"]==envs[1]["client_event_id"]
|
|
2216
|
+
and envs[1]["graph_version"]==9)
|
|
2217
|
+
print("SI" if ok else "NO %s" % json.dumps(envs))
|
|
2218
|
+
PYX
|
|
2219
|
+
echo "$out" | grep -qi "rancio\|grafo" && echo "OK el 409 stale se explica al usuario" \
|
|
2220
|
+
|| { echo "FAIL mensaje del 409 ($out)"; fail=1; }
|
|
2221
|
+
|
|
2222
|
+
# 404 anti-oráculo: el mensaje no puede afirmar cuál de las dos causas fue.
|
|
2223
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2224
|
+
import json,sys
|
|
2225
|
+
d=json.load(open(sys.argv[1]))
|
|
2226
|
+
d["POST /projects/p-1/agent/graph-sync-proposals"]={"status":404,"body":{"detail":"Proyecto no encontrado"}}
|
|
2227
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2228
|
+
PYX
|
|
2229
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2230
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/rejected/"*.json 2>/dev/null
|
|
2231
|
+
runtime_graph_clear_stale
|
|
2232
|
+
rc=0; out="$(bash "$SO" graph-sync --file "$TMP/gs-epics.json" 2>&1)" || rc=$?
|
|
2233
|
+
[ "$rc" = 6 ] && echo "OK graph-sync 404 -> rc 6" || { echo "FAIL 404 rc ($rc): $out"; fail=1; }
|
|
2234
|
+
echo "$out" | grep -qi "indistinguible\|no es el tuyo" \
|
|
2235
|
+
&& echo "OK el 404 se explica como anti-oráculo" || { echo "FAIL mensaje del 404 ($out)"; fail=1; }
|
|
2236
|
+
|
|
2237
|
+
# --- graph-sync-status: el veredicto humano, legible por la terminal que propuso ---
|
|
2238
|
+
# El motivo del rechazo lo escribe el ADMIN precisamente para que lo lea quien propuso: sin
|
|
2239
|
+
# este subcomando el veredicto se queda en la consola y el agente vuelve a proponer lo mismo.
|
|
2240
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2241
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:1","status":"PROPOSED","proposal_id":"gs-7"}'
|
|
2242
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:2","status":"PROPOSED","proposal_id":"gs-8"}'
|
|
2243
|
+
cat > "$TMP/routes-gs.json" <<'JSON'
|
|
2244
|
+
{"GET /projects/p-1/agent/graph-sync-proposals/gs-7": {"status": 200, "body": {"id": "gs-7", "status": "APPROVED", "graph_version": 4, "graph_version_applied": 5, "decided_by": "u-1", "reject_reason": null}},
|
|
2245
|
+
"GET /projects/p-1/agent/graph-sync-proposals/gs-8": {"status": 200, "body": {"id": "gs-8", "status": "REJECTED", "graph_version": 4, "reject_reason": "EP-014 no está en el PRD aprobado", "decided_by": "u-1"}}}
|
|
2246
|
+
JSON
|
|
2247
|
+
rc=0; out="$(bash "$SO" graph-sync-status 2>&1)" || rc=$?
|
|
2248
|
+
[ "$rc" = 0 ] && echo "OK graph-sync-status -> rc 0" || { echo "FAIL graph-sync-status rc ($rc): $out"; fail=1; }
|
|
2249
|
+
echo "$out" | grep -q "APPROVED" && echo "$out" | grep -q "v5" \
|
|
2250
|
+
&& echo "OK graph-sync-status muestra APPROVED con la versión aplicada" \
|
|
2251
|
+
|| { echo "FAIL render de APPROVED ($out)"; fail=1; }
|
|
2252
|
+
echo "$out" | grep -q "EP-014 no está en el PRD aprobado" \
|
|
2253
|
+
&& echo "OK graph-sync-status muestra el motivo del rechazo que escribió el ADMIN" \
|
|
2254
|
+
|| { echo "FAIL motivo del rechazo ($out)"; fail=1; }
|
|
2255
|
+
ops_graph_sync_ledger_read | python3 -c "
|
|
2256
|
+
import json,sys
|
|
2257
|
+
ps={p['proposal_id']:p for p in json.load(sys.stdin)['proposals']}
|
|
2258
|
+
print('SI' if ps['gs-7']['status']=='APPROVED' and ps['gs-8']['status']=='REJECTED' else 'NO')" \
|
|
2259
|
+
| grep -q SI && echo "OK el ledger se actualiza con el veredicto" \
|
|
2260
|
+
|| { echo "FAIL ledger tras el veredicto"; fail=1; }
|
|
2261
|
+
|
|
2262
|
+
out="$(bash "$SO" graph-sync-status --id gs-7 2>&1)"
|
|
2263
|
+
echo "$out" | grep -q "gs-7" && ! echo "$out" | grep -q "gs-8" \
|
|
2264
|
+
&& echo "OK --id filtra a una propuesta" || { echo "FAIL --id no filtra ($out)"; fail=1; }
|
|
2265
|
+
|
|
2266
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2267
|
+
rc=0; out="$(bash "$SO" graph-sync-status 2>&1)" || rc=$?
|
|
2268
|
+
[ "$rc" = 0 ] && echo "$out" | grep -qi "ninguna\|no hay" \
|
|
2269
|
+
&& echo "OK graph-sync-status sin propuestas -> rc 0 y lo dice" \
|
|
2270
|
+
|| { echo "FAIL sin propuestas (rc $rc): $out"; fail=1; }
|
|
2271
|
+
|
|
2272
|
+
rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" graph-sync-status 2>&1)" || rc=$?
|
|
2273
|
+
[ "$rc" = 3 ] && echo "OK graph-sync-status en legacy -> rc 3" || { echo "FAIL status legacy rc ($rc)"; fail=1; }
|
|
2274
|
+
|
|
2275
|
+
# Un 404 al consultar es el MISMO anti-oráculo: propuesta ajena o inexistente, sin distinguir.
|
|
2276
|
+
ops_graph_sync_ledger_upsert '{"client_event_id":"ak-1:graph-sync:9","status":"PROPOSED","proposal_id":"gs-99"}'
|
|
2277
|
+
out="$(bash "$SO" graph-sync-status --id gs-99 2>&1)"
|
|
2278
|
+
echo "$out" | grep -qi "indistinguible\|no es de este proyecto\|desconocida" \
|
|
2279
|
+
&& echo "OK una propuesta que el hub no reconoce se explica sin oráculo" \
|
|
2280
|
+
|| { echo "FAIL 404 del status ($out)"; fail=1; }
|
|
2281
|
+
rm -f "$(ops_graph_sync_ledger_path)"
|
|
2282
|
+
|
|
2283
|
+
# ══════════════ espejo dual: el cuerpo que el hub acepta de verdad [EP-OR-17] ══════════════
|
|
2284
|
+
[ "$OPS_RC_NOT_MIRRORABLE" = 8 ] && echo "OK OPS_RC_NOT_MIRRORABLE=8" || { echo "FAIL rc del espejo"; fail=1; }
|
|
2285
|
+
|
|
2286
|
+
# Réplica local de TIPOS_DE_ESPEJO del hub (event_catalog.py: los schemas que heredan de
|
|
2287
|
+
# _TransicionDeEspejo). Test ANTI-DRIFT: si el hub amplía la enumeración, esta lista tiene que
|
|
2288
|
+
# ampliarse a la vez — dos listas del mismo hecho divergen, y la que calla es la que miente.
|
|
2289
|
+
esperados="checkpoint_recorded gate_verdict handoff_recorded phase_advanced phase_reverted progress_noted slice_opened wiring_checklist_seeded wiring_item_updated"
|
|
2290
|
+
got="$(echo $OPS_MIRROR_TYPES | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')"
|
|
2291
|
+
[ "$got" = "$esperados" ] && echo "OK OPS_MIRROR_TYPES replica la enumeración normativa del hub" \
|
|
2292
|
+
|| { echo "FAIL OPS_MIRROR_TYPES ($got)"; fail=1; }
|
|
2293
|
+
|
|
2294
|
+
echo '{"gate":"security","status":"pass"}' > "$TMP/mir-payload.json"
|
|
2295
|
+
cat > "$TMP/routes-gs.json" <<'JSON'
|
|
2296
|
+
{"POST /projects/p-1/mirror/transitions": {"status": 200, "body": {"results": [{"client_event_id": "c1", "epic_code": "EP-003", "event_type": "gate_verdict", "status": "applied", "reason": null}], "applied": 1, "duplicates": 0, "rejected": 0}}}
|
|
2297
|
+
JSON
|
|
2298
|
+
|
|
2299
|
+
# Un tipo fuera de la enumeración NO gasta red: hoy viajaba y volvía rechazado por elemento, y
|
|
2300
|
+
# el aviso genérico mandaba al usuario a revisar una conexión que funcionaba.
|
|
2301
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2302
|
+
rc=0; err="$(ops_mirror release_verdict_reported "$TMP/mir-payload.json" EP-003 2>&1)" || rc=$?
|
|
2303
|
+
[ "$rc" = 8 ] && echo "OK un tipo no espejable -> rc 8" || { echo "FAIL tipo no espejable rc ($rc)"; fail=1; }
|
|
2304
|
+
grep -q 'mirror/transitions' "$TMP/calls-gs.jsonl" && { echo "FAIL un tipo no espejable tocó la red"; fail=1; } \
|
|
2305
|
+
|| echo "OK un tipo no espejable no abre socket"
|
|
2306
|
+
echo "$err" | grep -qi "catálogo\|enumeración" \
|
|
2307
|
+
&& echo "OK el aviso dice POR QUÉ no se espeja" || { echo "FAIL aviso del tipo no espejable ($err)"; fail=1; }
|
|
2308
|
+
|
|
2309
|
+
# Sin epic_code no se envía: el hub lo exige y el lote sería un 422 garantizado.
|
|
2310
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2311
|
+
rm -f "$PROJ/.claude/state/build-state.json"
|
|
2312
|
+
rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" "" >/dev/null 2>&1 || rc=$?
|
|
2313
|
+
[ "$rc" = 5 ] && echo "OK sin epic_code -> rc 5, sin red" || { echo "FAIL sin epic_code rc ($rc)"; fail=1; }
|
|
2314
|
+
grep -q 'mirror/transitions' "$TMP/calls-gs.jsonl" && { echo "FAIL sin epic_code tocó la red"; fail=1; } \
|
|
2315
|
+
|| echo "OK sin epic_code no abre socket"
|
|
2316
|
+
|
|
2317
|
+
# El epic_code cae al estado local cuando no se pasa: en dual el fichero es primario.
|
|
2318
|
+
echo '{"active_slice":{"epica":"EP-077","phase":"green"}}' > "$PROJ/.claude/state/build-state.json"
|
|
2319
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2320
|
+
ops_mirror gate_verdict "$TMP/mir-payload.json" >/dev/null 2>&1
|
|
2321
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2322
|
+
&& echo "OK el epic_code cae a active_slice.epica del estado local" \
|
|
2323
|
+
|| { echo "FAIL fallback del epic_code"; fail=1; }
|
|
2324
|
+
import json,sys
|
|
2325
|
+
env=None
|
|
2326
|
+
for line in open(sys.argv[1]):
|
|
2327
|
+
c=json.loads(line)
|
|
2328
|
+
if c["path"].endswith("/mirror/transitions"):
|
|
2329
|
+
env=json.loads(c["body"])
|
|
2330
|
+
print("SI" if env and env["transitions"][0]["epic_code"]=="EP-077" else "NO %s" % json.dumps(env))
|
|
2331
|
+
PYX
|
|
2332
|
+
|
|
2333
|
+
# El cuerpo es {"transitions":[{client_event_id, epic_code, event_type, payload}]} — y el
|
|
2334
|
+
# payload NO lleva claves de más (los schemas del catálogo son extra="forbid").
|
|
2335
|
+
: > "$TMP/calls-gs.jsonl"
|
|
2336
|
+
rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 >/dev/null 2>&1 || rc=$?
|
|
2337
|
+
[ "$rc" = 0 ] && echo "OK espejo aplicado -> rc 0" || { echo "FAIL espejo aplicado rc ($rc)"; fail=1; }
|
|
2338
|
+
python3 - "$TMP/calls-gs.jsonl" <<'PYX' | grep -q SI \
|
|
2339
|
+
&& echo "OK el espejo manda el shape MirrorTransitionsIn" || { echo "FAIL shape del espejo"; fail=1; }
|
|
2340
|
+
import json,sys
|
|
2341
|
+
env=None
|
|
2342
|
+
for line in open(sys.argv[1]):
|
|
2343
|
+
c=json.loads(line)
|
|
2344
|
+
if c["path"].endswith("/mirror/transitions"):
|
|
2345
|
+
env=json.loads(c["body"])
|
|
2346
|
+
t=(env or {}).get("transitions") or [{}]
|
|
2347
|
+
ok = (list((env or {}).keys())==["transitions"] and len(t)==1
|
|
2348
|
+
and set(t[0].keys())=={"client_event_id","epic_code","event_type","payload"}
|
|
2349
|
+
and t[0]["epic_code"]=="EP-003" and t[0]["event_type"]=="gate_verdict"
|
|
2350
|
+
and 1 <= len(t[0]["client_event_id"]) <= 64
|
|
2351
|
+
and set(t[0]["payload"].keys())=={"gate","status"})
|
|
2352
|
+
print("SI" if ok else "NO %s" % json.dumps(env))
|
|
2353
|
+
PYX
|
|
2354
|
+
|
|
2355
|
+
# Un duplicado no es un fallo: el hub deduplica por client_event_id y el trabajo local siguió.
|
|
2356
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2357
|
+
import json,sys
|
|
2358
|
+
d=json.load(open(sys.argv[1]))
|
|
2359
|
+
r=d["POST /projects/p-1/mirror/transitions"]["body"]
|
|
2360
|
+
r["results"][0]["status"]="duplicate"; r["applied"]=0; r["duplicates"]=1
|
|
2361
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2362
|
+
PYX
|
|
2363
|
+
rc=0; ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 >/dev/null 2>&1 || rc=$?
|
|
2364
|
+
[ "$rc" = 0 ] && echo "OK espejo duplicado -> rc 0" || { echo "FAIL espejo duplicado rc ($rc)"; fail=1; }
|
|
2365
|
+
|
|
2366
|
+
# Un rechazo POR ELEMENTO se lee y se muestra: es el motivo, no el cuerpo crudo.
|
|
2367
|
+
python3 - "$TMP/routes-gs.json" <<'PYX'
|
|
2368
|
+
import json,sys
|
|
2369
|
+
d=json.load(open(sys.argv[1]))
|
|
2370
|
+
r=d["POST /projects/p-1/mirror/transitions"]["body"]
|
|
2371
|
+
r["results"][0]["status"]="rejected"
|
|
2372
|
+
r["results"][0]["reason"]="la épica 'EP-003' no existe en el grafo del proyecto"
|
|
2373
|
+
r["applied"]=0; r["duplicates"]=0; r["rejected"]=1
|
|
2374
|
+
json.dump(d,open(sys.argv[1],"w"))
|
|
2375
|
+
PYX
|
|
2376
|
+
rc=0; err="$(ops_mirror gate_verdict "$TMP/mir-payload.json" EP-003 2>&1)" || rc=$?
|
|
2377
|
+
[ "$rc" = 6 ] && echo "OK espejo rechazado -> rc 6" || { echo "FAIL espejo rechazado rc ($rc)"; fail=1; }
|
|
2378
|
+
echo "$err" | grep -q "no existe en el grafo" \
|
|
2379
|
+
&& echo "OK el rechazo del espejo muestra el motivo POR ELEMENTO" \
|
|
2380
|
+
|| { echo "FAIL motivo del rechazo del espejo ($err)"; fail=1; }
|
|
2381
|
+
echo "$err" | grep -q "graph-sync" \
|
|
2382
|
+
&& echo "OK el rechazo por épica ausente apunta a graph-sync" \
|
|
2383
|
+
|| { echo "FAIL el rechazo no sugiere graph-sync ($err)"; fail=1; }
|
|
2384
|
+
rm -f "$PROJ/.claude/state/build-state.json"
|
|
2385
|
+
|
|
2002
2386
|
# ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
|
|
2003
2387
|
GB="$ROOT/scripts/lib/graph-bundle.py"
|
|
2004
2388
|
cat > "$TMP/epics.json" <<'JSON'
|
|
@@ -2153,6 +2537,66 @@ grep -q "EP-020 tiene sección" "$TMP/gb10.err" && grep -q "EP-021 no aparece co
|
|
|
2153
2537
|
|
|
2154
2538
|
# 4-ter) Las dos banderas juntas son un error explícito (ignorar una en silencio es el
|
|
2155
2539
|
# patrón que causó #56).
|
|
2540
|
+
# ══════════ graph-bundle.py --for-sync [EP-OR-17] ══════════
|
|
2541
|
+
# La proyección de contrato del carril `graph-sync`. `GraphSyncBundle` es extra="forbid" y
|
|
2542
|
+
# admite SOLO `epics`: cualquier clave de preparación (bundle_version, kind, project_ref,
|
|
2543
|
+
# warnings…) es un 422. Y lo que en el bundle de preparación es AVISO aquí es RECHAZO: sin un
|
|
2544
|
+
# ADMIN revisando antes de subirlo, un aviso ignorado es el 422 del hub con pasos extra.
|
|
2545
|
+
cat > "$TMP/sync-in.json" <<'JSON'
|
|
2546
|
+
{"project_ref": "demo",
|
|
2547
|
+
"epics": [{"code": "EP-002", "title": "Cobros", "layer": "business",
|
|
2548
|
+
"files_scope": ["src/cobros/**"], "depends_on": ["EP-001"],
|
|
2549
|
+
"stories": [{"id": "HU-011", "title": "Registrar cobro"}]},
|
|
2550
|
+
{"code": "EP-001", "title": "Cimiento", "layer": "foundational",
|
|
2551
|
+
"stories": []}],
|
|
2552
|
+
"release_lines": [{"id": "R1", "epics": ["EP-001", "EP-002"]}]}
|
|
2553
|
+
JSON
|
|
2554
|
+
rc=0; out="$(python3 "$GB" --for-sync < "$TMP/sync-in.json" 2>"$TMP/sync-err.txt")" || rc=$?
|
|
2555
|
+
[ "$rc" = 0 ] && echo "OK --for-sync con bundle válido -> rc 0" \
|
|
2556
|
+
|| { echo "FAIL --for-sync rc ($rc): $(cat "$TMP/sync-err.txt")"; fail=1; }
|
|
2557
|
+
printf '%s' "$out" | python3 -c "
|
|
2558
|
+
import json,sys
|
|
2559
|
+
d=json.load(sys.stdin)
|
|
2560
|
+
e=d['epics']
|
|
2561
|
+
ok = (list(d.keys())==['epics'] and len(e)==2 and e[0]['code']=='EP-001'
|
|
2562
|
+
and e[1]['layer']=='BUSINESS' and e[1]['stories'][0]['code']=='HU-011'
|
|
2563
|
+
and e[1]['release_line']=='R1'
|
|
2564
|
+
and not any(k in e[0] for k in ('warnings','bundle_version','kind','project_ref')))
|
|
2565
|
+
print('SI' if ok else 'NO')" | grep -q SI \
|
|
2566
|
+
&& echo "OK --for-sync emite solo {epics:[…]} con el shape del contrato" \
|
|
2567
|
+
|| { echo "FAIL shape de --for-sync ($out)"; fail=1; }
|
|
2568
|
+
|
|
2569
|
+
echo '{"epics":[{"code":"EP-009","title":"Sin capa"}]}' > "$TMP/sync-nolayer.json"
|
|
2570
|
+
rc=0; python3 "$GB" --for-sync < "$TMP/sync-nolayer.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
|
|
2571
|
+
[ "$rc" = 2 ] && echo "OK --for-sync sin layer -> rc 2" || { echo "FAIL layer ausente rc ($rc)"; fail=1; }
|
|
2572
|
+
grep -q "EP-009" "$TMP/sync-err.txt" && grep -qi "layer" "$TMP/sync-err.txt" \
|
|
2573
|
+
&& echo "OK el rechazo nombra la épica y el campo" \
|
|
2574
|
+
|| { echo "FAIL mensaje de layer ausente ($(cat "$TMP/sync-err.txt"))"; fail=1; }
|
|
2575
|
+
|
|
2576
|
+
python3 -c "
|
|
2577
|
+
import json
|
|
2578
|
+
hs=[{'id':'HU-%03d'%i,'title':'h%d'%i} for i in range(101)]
|
|
2579
|
+
print(json.dumps({'epics':[{'code':'EP-010','title':'Muchas','layer':'BUSINESS','stories':hs}]}))" > "$TMP/sync-techo.json"
|
|
2580
|
+
rc=0; python3 "$GB" --for-sync < "$TMP/sync-techo.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
|
|
2581
|
+
[ "$rc" = 2 ] && echo "OK --for-sync con 101 historias -> rc 2" || { echo "FAIL techo de historias rc ($rc)"; fail=1; }
|
|
2582
|
+
grep -q "100" "$TMP/sync-err.txt" && echo "OK el rechazo por techo dice cuál es el techo" \
|
|
2583
|
+
|| { echo "FAIL mensaje de techo ($(cat "$TMP/sync-err.txt"))"; fail=1; }
|
|
2584
|
+
|
|
2585
|
+
echo '{"epics":[]}' > "$TMP/sync-vacio.json"
|
|
2586
|
+
rc=0; python3 "$GB" --for-sync < "$TMP/sync-vacio.json" >/dev/null 2>"$TMP/sync-err.txt" || rc=$?
|
|
2587
|
+
[ "$rc" = 2 ] && echo "OK --for-sync sin épicas -> rc 2" || { echo "FAIL bundle vacío rc ($rc)"; fail=1; }
|
|
2588
|
+
|
|
2589
|
+
# El modo de preparación NO cambia: sigue tolerando lo que el ADMIN va a revisar.
|
|
2590
|
+
rc=0; out="$(python3 "$GB" < "$TMP/sync-nolayer.json" 2>/dev/null)" || rc=$?
|
|
2591
|
+
[ "$rc" = 0 ] && printf '%s' "$out" | grep -q '"bundle_version"' \
|
|
2592
|
+
&& echo "OK el modo de preparación sigue tolerando layer ausente (rc 0 + avisos)" \
|
|
2593
|
+
|| { echo "FAIL regresión en el modo de preparación (rc $rc)"; fail=1; }
|
|
2594
|
+
|
|
2595
|
+
# --for-sync se combina con --from-docs: las dependencias en prosa también viajan al hub.
|
|
2596
|
+
rc=0; out="$(python3 "$GB" --for-sync --from-docs "$TMP/docs/epicas.md" < "$TMP/sync-in.json" 2>/dev/null)" || rc=$?
|
|
2597
|
+
[ "$rc" = 0 ] && echo "OK --for-sync se combina con --from-docs" \
|
|
2598
|
+
|| { echo "FAIL --for-sync con --from-docs (rc $rc)"; fail=1; }
|
|
2599
|
+
|
|
2156
2600
|
echo '{"epics":[]}' | python3 "$GB" --from-docs "$TMP/docs/epicas.md" --print-deps "$TMP/docs/epicas.md" > /dev/null 2> "$TMP/gb11.err"
|
|
2157
2601
|
[ "$?" = 1 ] && grep -qi "excluyentes" "$TMP/gb11.err" \
|
|
2158
2602
|
&& echo "OK --from-docs y --print-deps juntas -> rc 1 explícito" || { echo "FAIL banderas excluyentes"; fail=1; }
|
|
@@ -2171,7 +2615,7 @@ PORT_GV="$(freeport)"
|
|
|
2171
2615
|
cat > "$TMP/routes-gv.json" <<JSON
|
|
2172
2616
|
{"GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": {}, "nudges": [],
|
|
2173
2617
|
"context": {"manifest_hash": "sha256:v2", "version": 9, "graph_version": 45}}},
|
|
2174
|
-
"POST /tasks/next": {"status": 409, "body": {"
|
|
2618
|
+
"POST /tasks/next": {"status": 409, "body": {"detail": {"error": "graph_version_stale", "message": "el grafo avanzo", "graph_version": 45}}}}
|
|
2175
2619
|
JSON
|
|
2176
2620
|
: > "$TMP/calls-gv.jsonl"
|
|
2177
2621
|
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GV" "$TMP/routes-gv.json" "$TMP/calls-gv.jsonl" &
|
|
@@ -2231,6 +2675,51 @@ PY
|
|
|
2231
2675
|
[ "$has" = NO ] && [ $rc -eq 7 ] && echo "OK sin hub que versione, el claim no manda nada nuevo" \
|
|
2232
2676
|
|| { echo "FAIL compatibilidad hacia atras del claim (has=$has rc=$rc)"; fail=1; }
|
|
2233
2677
|
|
|
2678
|
+
# --- [EP-OR-17] Los OTROS motivos del 409, como los emite el hub de verdad ---
|
|
2679
|
+
# El `case` esperaba `reason: drift|contention|…` en la raíz y el hub anida todo bajo `detail`:
|
|
2680
|
+
# los cuatro casos caían en el `*)` y salían como rechazo duro. Un drift es REINTENTABLE tras
|
|
2681
|
+
# sincronizar el contexto, y reportarlo como rechazo dejaba al agente sin claim y sin salida.
|
|
2682
|
+
python3 - "$TMP/routes-gv.json" <<'PY'
|
|
2683
|
+
import json,sys
|
|
2684
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
2685
|
+
d["POST /tasks/next"]={"status":409,"body":{"detail":{"message":"tu contexto local no coincide","manifest_hash":"sha256:v9"}}}
|
|
2686
|
+
json.dump(d,open(p,"w"))
|
|
2687
|
+
PY
|
|
2688
|
+
: > "$TMP/calls-gv.jsonl"
|
|
2689
|
+
out="$(bash "$SO" claim 2>&1)"; rc=$?
|
|
2690
|
+
n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
|
|
2691
|
+
[ "$n" = 2 ] && echo "OK un 409 de drift (detail.manifest_hash) se reintenta una vez" \
|
|
2692
|
+
|| { echo "FAIL reintentos del 409 de drift ($n)"; fail=1; }
|
|
2693
|
+
[ $rc -eq 7 ] && echo "OK un drift persistente acaba en NO_WORK, no en rechazo duro" \
|
|
2694
|
+
|| { echo "FAIL rc del 409 de drift ($rc): $out"; fail=1; }
|
|
2695
|
+
|
|
2696
|
+
python3 - "$TMP/routes-gv.json" <<'PY'
|
|
2697
|
+
import json,sys
|
|
2698
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
2699
|
+
d["POST /tasks/next"]={"status":409,"body":{"detail":"contención con otra escritura del contexto: reintenta el claim"}}
|
|
2700
|
+
json.dump(d,open(p,"w"))
|
|
2701
|
+
PY
|
|
2702
|
+
: > "$TMP/calls-gv.jsonl"
|
|
2703
|
+
out="$(bash "$SO" claim 2>&1)"; rc=$?
|
|
2704
|
+
n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
|
|
2705
|
+
[ "$n" = 2 ] && [ $rc -eq 7 ] && echo "OK un 409 de contención se reintenta una vez" \
|
|
2706
|
+
|| { echo "FAIL 409 de contención (n=$n rc=$rc): $out"; fail=1; }
|
|
2707
|
+
|
|
2708
|
+
python3 - "$TMP/routes-gv.json" <<'PY'
|
|
2709
|
+
import json,sys
|
|
2710
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
2711
|
+
d["POST /tasks/next"]={"status":409,"body":{"detail":"el contexto vigente del proyecto no resuelve (manifiesto ausente): no hay trabajo sin contexto declarado"}}
|
|
2712
|
+
json.dump(d,open(p,"w"))
|
|
2713
|
+
PY
|
|
2714
|
+
: > "$TMP/calls-gv.jsonl"
|
|
2715
|
+
out="$(bash "$SO" claim 2>&1)"; rc=$?
|
|
2716
|
+
n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-gv.jsonl" | tr -d ' ')"
|
|
2717
|
+
[ "$n" = 1 ] && [ $rc -eq 6 ] && echo "OK un contexto irresoluble NO se reintenta (rc 6)" \
|
|
2718
|
+
|| { echo "FAIL 409 de contexto irresoluble (n=$n rc=$rc): $out"; fail=1; }
|
|
2719
|
+
echo "$out" | grep -q "no hay trabajo sin contexto declarado" \
|
|
2720
|
+
&& echo "OK el 409 enseña el motivo del hub en prosa, no el JSON crudo" \
|
|
2721
|
+
|| { echo "FAIL prosa del 409 ($out)"; fail=1; }
|
|
2722
|
+
|
|
2234
2723
|
kill "$STUB_GV" 2>/dev/null; wait "$STUB_GV" 2>/dev/null
|
|
2235
2724
|
|
|
2236
2725
|
exit $fail
|