@trycore/spec-build-harness 0.10.0 → 0.11.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/VERSION +1 -1
- package/commands/build/escalate.md +1 -1
- package/commands/build/onboard.md +20 -4
- package/dist/cli.js +11 -1
- package/dist/commands/migrate.js +107 -2
- package/dist/lib/normalize.js +925 -78
- package/dist/lib/state-bundle.js +112 -8
- package/docs/runtime/guia-modo-dual-y-migracion.md +9 -2
- package/docs/runtime/protocolo-cliente-runtime.md +29 -19
- package/hooks/build/event-emitter.sh +9 -52
- package/hooks/build/lib/runtime-client.sh +183 -23
- package/hooks/build/lib/runtime-ops.sh +14 -8
- package/hooks/build/lib/state-io.sh +0 -0
- package/hooks/build/release-ops.sh +16 -9
- package/hooks/build/slice-ops.sh +152 -81
- package/package.json +1 -1
- package/scripts/check-runtime-purity.sh +0 -0
- package/scripts/denylist.txt +4 -0
- package/scripts/lib/graph-bundle.py +68 -20
- package/scripts/tests/test-baseline-verdict.sh +0 -0
- package/scripts/tests/test-hooks-runtime.sh +13 -38
- package/scripts/tests/test-install.sh +46 -0
- package/scripts/tests/test-skill-ops.sh +578 -58
- package/skills/building-a-slice/references/runtime-protocol.md +1 -1
- package/skills/releasing-a-version/SKILL.md +2 -1
|
@@ -50,10 +50,16 @@ printf 'allow-list-v1' > "$PROJ/.claude/config/stack-allowlist.json"
|
|
|
50
50
|
runtime_lock_write "sha256:v1" '[{"path":"config/stack-allowlist.json","sha256":"sha-del-servidor"},{"path":"rules/no-existe.md","sha256":"otro"}]'
|
|
51
51
|
got="$(ops_context_hashes)"
|
|
52
52
|
real="$(python3 -c "import hashlib;print(hashlib.sha256(open('$PROJ/.claude/config/stack-allowlist.json','rb').read()).hexdigest())")"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
# Contrato del hub (#36): `TasksNextIn.context_hashes` es `dict[str, str]` — una LISTA
|
|
54
|
+
# provoca 422 dict_type en TODO claim. Se exige {path: sha}, jamás [{"path":…}].
|
|
55
|
+
echo "$got" | python3 -c "
|
|
56
|
+
import json,sys
|
|
57
|
+
d=json.load(sys.stdin)
|
|
58
|
+
print('SI' if isinstance(d,dict) and d.get('config/stack-allowlist.json')=='$real' else 'NO')" | grep -q SI \
|
|
59
|
+
&& echo "OK ops_context_hashes emite dict {path: sha} con el sha local efectivo" || { echo "FAIL ops_context_hashes ($got)"; fail=1; }
|
|
60
|
+
echo "$got" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("SI" if d.get("rules/no-existe.md")=="" else "NO")' | grep -q SI \
|
|
61
|
+
&& echo "OK ops_context_hashes marca \"\" el fichero ausente (el dict del hub no admite null)" || { echo "FAIL ops_context_hashes ausente ($got)"; fail=1; }
|
|
62
|
+
[ "$(PATH=/var/empty ops_context_hashes)" = "{}" ] && echo "OK ops_context_hashes sin python3 -> {}" || { echo "FAIL ops_context_hashes sin python3"; fail=1; }
|
|
57
63
|
|
|
58
64
|
# --- ops_enqueue_from_file: payload por FICHERO (sin límite de ARG_MAX) ---
|
|
59
65
|
python3 -c "
|
|
@@ -70,14 +76,27 @@ ok = d['type']=='wiring_checklist_seeded' and d.get('slice_id')=='sl-1' and len(
|
|
|
70
76
|
print('SI' if ok else 'NO')" | grep -q SI && echo "OK ops_enqueue_from_file conserva tipo/slice_id/payload" || { echo "FAIL contenido del evento encolado"; fail=1; }
|
|
71
77
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
72
78
|
|
|
73
|
-
# --- lista protegida
|
|
74
|
-
|
|
79
|
+
# --- lista protegida: nombres del catálogo v2 del hub [#44] ---
|
|
80
|
+
# (#38: los hechos máquina viajan como UN solo tipo del catálogo v2, project_fact_updated;
|
|
81
|
+
# #44: los tipos protegidos llevan el NOMBRE del catálogo — proteger de la evicción un tipo
|
|
82
|
+
# que el hub va a rechazar era absurdo. Los alias 0.10.x de los cuatro renombrados siguen
|
|
83
|
+
# protegidos porque la capa de compat del despacho los traduce y entrega.)
|
|
84
|
+
for t in slice_archived wiring_item_updated slice_escalated gate_verdict checkpoint_recorded \
|
|
85
|
+
project_fact_updated wiring_checklist_seeded slice_submitted handoff_recorded telemetry_gap \
|
|
86
|
+
checkpoint_created verdict_reported escalation_raised; do
|
|
75
87
|
case " $RUNTIME_OUTBOX_PROTECTED " in
|
|
76
88
|
*" $t "*) : ;;
|
|
77
89
|
*) echo "FAIL $t no está en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
|
|
78
90
|
esac
|
|
79
91
|
done
|
|
80
|
-
|
|
92
|
+
# Tipos sin equivalente en el catálogo: fuera de la lista (el hub los rechazaría siempre).
|
|
93
|
+
for t in front_integration_reported branch_drift tool_use_recorded progress_noted progress_note_recorded; do
|
|
94
|
+
case " $RUNTIME_OUTBOX_PROTECTED " in
|
|
95
|
+
*" $t "*) echo "FAIL $t no debe estar en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
|
|
96
|
+
*) : ;;
|
|
97
|
+
esac
|
|
98
|
+
done
|
|
99
|
+
echo "OK lista protegida con nombres del catálogo v2 (si no hubo FAIL arriba)"
|
|
81
100
|
# Regresión de comportamiento (no de la constante): 6 MB de tool_use + 1 slice_archived.
|
|
82
101
|
runtime_enqueue_event slice_archived '{"epic_code":"EP-001"}' sl-1
|
|
83
102
|
python3 - "$PROJ/.claude/state/outbox" <<'PY'
|
|
@@ -139,7 +158,18 @@ STUBS="$STUBS $!"
|
|
|
139
158
|
sleep 0.3
|
|
140
159
|
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\"}"
|
|
141
160
|
|
|
142
|
-
|
|
161
|
+
# (#39) `claim --epic` NO existe en el protocolo del hub: `TasksNextIn` no declara
|
|
162
|
+
# epic_code (pydantic lo ignoraba y la cola repartía OTRA épica con claim "exitoso").
|
|
163
|
+
# Debe fallar EXPLÍCITO antes de tocar la red.
|
|
164
|
+
: > "$TMP/calls-cl.jsonl"
|
|
165
|
+
rc=0; err="$(bash "$SO" claim --epic EP-003 2>&1 >/dev/null)" || rc=$?
|
|
166
|
+
[ "$rc" = 2 ] && echo "OK claim --epic en runtime -> rc 2 (uso, sin red)" || { echo "FAIL claim --epic rc ($rc)"; fail=1; }
|
|
167
|
+
echo "$err" | grep -qi "cola" && echo "$err" | grep -q "epic" \
|
|
168
|
+
&& echo "OK claim --epic explica que el hub reparte por orden de cola" || { echo "FAIL mensaje de claim --epic ($err)"; fail=1; }
|
|
169
|
+
grep -q '"path": "/tasks/next"' "$TMP/calls-cl.jsonl" && { echo "FAIL claim --epic llamó a la red"; fail=1; } \
|
|
170
|
+
|| echo "OK claim --epic no abre socket"
|
|
171
|
+
|
|
172
|
+
rc=0; out="$(bash "$SO" claim)" || rc=$?
|
|
143
173
|
[ "$rc" = 0 ] && echo "OK claim 200 -> rc 0" || { echo "FAIL claim 200 rc ($rc)"; fail=1; }
|
|
144
174
|
echo "$out" | grep -q "sl-7" && echo "OK claim rinde el slice reclamado" || { echo "FAIL claim render ($out)"; fail=1; }
|
|
145
175
|
[ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
|
|
@@ -150,10 +180,12 @@ for line in open(sys.argv[1]):
|
|
|
150
180
|
if c["path"]=="/tasks/next":
|
|
151
181
|
b=json.loads(c["body"])
|
|
152
182
|
# POST siempre: el GET no acepta el reporte de hashes (spec §3.1 rev. 4).
|
|
153
|
-
|
|
183
|
+
# (#36) context_hashes es DICT (ClaimIn del hub: dict[str,str]); (#39) epic_code
|
|
184
|
+
# jamás viaja — el hub no lo declara y lo ignoraría en silencio.
|
|
185
|
+
ok = c["method"]=="POST" and isinstance(b.get("context_hashes"), dict) and "epic_code" not in b
|
|
154
186
|
print("SI" if ok else "NO")
|
|
155
187
|
PYX
|
|
156
|
-
)" = SI ] && echo "OK el claim usa POST
|
|
188
|
+
)" = SI ] && echo "OK el claim usa POST con context_hashes dict y sin epic_code" || { echo "FAIL cuerpo/método del claim"; fail=1; }
|
|
157
189
|
[ "$(projection_get active_slice.slice_id "")" = "sl-7" ] && echo "OK el claim refresca la caché de proyección" || { echo "FAIL la caché no se refrescó"; fail=1; }
|
|
158
190
|
|
|
159
191
|
# next-step: deriva del cliente (spec §2), prioridad resume_hint > wiring > sub-slices > gate.
|
|
@@ -245,26 +277,59 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
|
|
|
245
277
|
# ══════════════ slice-ops.sh: fact / propose-asset [Task 5] ══════════════
|
|
246
278
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
247
279
|
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
&& echo "OK
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
280
|
+
# (#38) Hechos HUMANOS: el catálogo v2 del hub los rechaza en TODA superficie de agente
|
|
281
|
+
# (HECHOS_HUMANOS en project_facts_domain) — se fijan tras el PDP por la superficie admin
|
|
282
|
+
# (PATCH /orchestrator/projects/{project_id}). En runtime/dual NO se encola: rechazo local
|
|
283
|
+
# con mensaje que dirige al humano a la consola del hub.
|
|
284
|
+
rc=0; err="$(bash "$SO" fact scaffold-confirmed --by "ana@equipo" --notes "npm run dev arranca vacío" 2>&1 >/dev/null)" || rc=$?
|
|
285
|
+
[ "$rc" = 6 ] && echo "OK fact scaffold-confirmed en runtime -> rc 6 (hecho humano)" || { echo "FAIL fact scaffold rc ($rc)"; fail=1; }
|
|
286
|
+
echo "$err" | grep -q "PATCH /orchestrator/projects" \
|
|
287
|
+
&& echo "OK el rechazo del hecho humano nombra la superficie admin" || { echo "FAIL mensaje de scaffold-confirmed ($err)"; fail=1; }
|
|
288
|
+
[ "$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')" = 0 ] \
|
|
289
|
+
&& echo "OK el hecho humano NO se encola (adiós reintento eterno)" || { echo "FAIL scaffold-confirmed encoló"; fail=1; }
|
|
290
|
+
|
|
291
|
+
rc=0; err="$(bash "$SO" fact design-source --applies true --source "docs/05-prototipo/index.html" --confirmed true --by "ana@equipo" 2>&1 >/dev/null)" || rc=$?
|
|
292
|
+
[ "$rc" = 6 ] && echo "$err" | grep -q "PATCH /orchestrator/projects" \
|
|
293
|
+
&& echo "OK fact design-source en runtime -> rc 6 con remisión al admin" || { echo "FAIL fact design-source ($rc: $err)"; fail=1; }
|
|
294
|
+
|
|
295
|
+
# (#38) Hechos MÁQUINA: UN solo tipo del catálogo v2, `project_fact_updated`, con el
|
|
296
|
+
# payload EXACTO {fact, value, source:"AGENT"} (extra="forbid" en el hub), SIN slice_id.
|
|
260
297
|
rc=0; bash "$SO" fact project-kind greenfield --source human >/dev/null || rc=$?
|
|
261
|
-
[ "$rc" = 0 ] &&
|
|
262
|
-
|
|
298
|
+
[ "$rc" = 0 ] && echo "OK fact project-kind -> rc 0" || { echo "FAIL fact project-kind rc ($rc)"; fail=1; }
|
|
299
|
+
f="$(grep -l '"fact": "project_kind"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
300
|
+
[ -n "$f" ] && [ "$(python3 - "$f" <<'PYX'
|
|
301
|
+
import json,sys
|
|
302
|
+
d=json.load(open(sys.argv[1]))
|
|
303
|
+
ok = d.get("type")=="project_fact_updated" and "slice_id" not in d \
|
|
304
|
+
and d.get("payload")=={"fact":"project_kind","value":"greenfield","source":"AGENT"}
|
|
305
|
+
print("SI" if ok else "NO")
|
|
306
|
+
PYX
|
|
307
|
+
)" = SI ] \
|
|
308
|
+
&& echo "OK project-kind viaja como project_fact_updated {fact,value,source} sin slice_id" \
|
|
309
|
+
|| { echo "FAIL payload de project-kind"; fail=1; }
|
|
310
|
+
|
|
263
311
|
echo '[{"id":"homepage","applies":true},{"id":"login-authn","applies":false}]' > "$TMP/checklist.json"
|
|
264
312
|
rc=0; bash "$SO" fact foundation --required true --epic EP-000 --checklist-file "$TMP/checklist.json" >/dev/null || rc=$?
|
|
265
|
-
f="$(grep -l '"
|
|
266
|
-
|
|
267
|
-
|
|
313
|
+
f="$(grep -l '"fact": "foundation"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
314
|
+
# El hub proyecta foundation a un Boolean: el value es bool, sin epic/checklist extra
|
|
315
|
+
# (extra=forbid los rechazaría); la checklist queda en los docs locales.
|
|
316
|
+
[ "$rc" = 0 ] && [ -n "$f" ] && [ "$(python3 - "$f" <<'PYX'
|
|
317
|
+
import json,sys
|
|
318
|
+
p=json.load(open(sys.argv[1]))["payload"]
|
|
319
|
+
print("SI" if p=={"fact":"foundation","value":True,"source":"AGENT"} else "NO")
|
|
320
|
+
PYX
|
|
321
|
+
)" = SI ] \
|
|
322
|
+
&& echo "OK fact foundation viaja como project_fact_updated con value booleano" || { echo "FAIL fact foundation"; fail=1; }
|
|
323
|
+
|
|
324
|
+
rc=0; bash "$SO" fact harness-phase building >/dev/null || rc=$?
|
|
325
|
+
f="$(grep -l '"fact": "harness_phase"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
326
|
+
[ "$rc" = 0 ] && [ -n "$f" ] && [ "$(python3 - "$f" <<'PYX'
|
|
327
|
+
import json,sys
|
|
328
|
+
p=json.load(open(sys.argv[1]))["payload"]
|
|
329
|
+
print("SI" if p=={"fact":"harness_phase","value":"building","source":"AGENT"} else "NO")
|
|
330
|
+
PYX
|
|
331
|
+
)" = SI ] \
|
|
332
|
+
&& echo "OK fact harness-phase viaja como project_fact_updated" || { echo "FAIL fact harness-phase"; fail=1; }
|
|
268
333
|
|
|
269
334
|
# Valor inválido: rechazo local, sin encolar.
|
|
270
335
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
@@ -278,10 +343,10 @@ rc=0; bash "$SO" fact project-kind marciano >/dev/null 2>&1 || rc=$?
|
|
|
278
343
|
# outbox y confirmamos que mkdir/tempfile fallan de verdad antes de aserir el rc.
|
|
279
344
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
280
345
|
chmod 555 "$PROJ/.claude/state/outbox"
|
|
281
|
-
rc=0; out="$(bash "$SO" fact
|
|
346
|
+
rc=0; out="$(bash "$SO" fact project-kind greenfield 2>&1)" || rc=$?
|
|
282
347
|
chmod 755 "$PROJ/.claude/state/outbox"
|
|
283
348
|
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
284
|
-
[ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q '
|
|
349
|
+
[ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'encolado' \
|
|
285
350
|
&& echo "OK fact con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
|
|
286
351
|
|| { echo "FAIL fact con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
|
|
287
352
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
@@ -336,12 +401,18 @@ rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" status --no-refresh)" || rc=
|
|
|
336
401
|
agent_context_fetch_and_cache
|
|
337
402
|
|
|
338
403
|
# escalate: evento tipado + instrucción de gobierno humano.
|
|
404
|
+
# [#44] El catálogo v2 lo llama `slice_escalated` y su payload es EXACTAMENTE {cause}
|
|
405
|
+
# (extra="forbid" en el hub): gate y fase viajan DENTRO del texto de la causa.
|
|
339
406
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
340
407
|
rc=0; out="$(bash "$SO" escalate "el AC-3 contradice el prototipo" --gate fidelity)" || rc=$?
|
|
341
408
|
[ "$rc" = 0 ] && echo "OK escalate -> rc 0" || { echo "FAIL escalate rc ($rc)"; fail=1; }
|
|
342
|
-
f="$(grep -l '"type": "
|
|
343
|
-
[ -n "$f" ] && [ "$(python3 -c "
|
|
344
|
-
|
|
409
|
+
f="$(grep -l '"type": "slice_escalated"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
410
|
+
[ -n "$f" ] && [ "$(python3 -c "
|
|
411
|
+
import json;d=json.load(open('$f'))
|
|
412
|
+
p=d['payload']
|
|
413
|
+
ok = set(p.keys())=={'cause'} and 'fidelity' in p['cause'] and 'AC-3' in p['cause'] and d.get('slice_id')=='sl-7'
|
|
414
|
+
print('SI' if ok else 'NO')")" = SI ] \
|
|
415
|
+
&& echo "OK escalate encola slice_escalated {cause} (gate y fase dentro del texto) con slice_id" || { echo "FAIL payload de escalate"; fail=1; }
|
|
345
416
|
echo "$out" | grep -qi "human" && echo "OK escalate declara que la decisión es humana" || { echo "FAIL escalate sin nota de gobierno"; fail=1; }
|
|
346
417
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
347
418
|
|
|
@@ -462,12 +533,26 @@ for sub in "verdict R1-mvp security pass" "front-integration fr-1 --merge-status
|
|
|
462
533
|
done
|
|
463
534
|
[ ! -s "$TMP/curl-legacy.log" ] && echo "OK release-ops no toca la red en legacy" || { echo "FAIL release-ops abrió socket en legacy"; fail=1; }
|
|
464
535
|
|
|
465
|
-
# Offline: el veredicto de release se encola
|
|
536
|
+
# Offline: el veredicto de release NO se encola [#44] — `release_gate_verdict` es del
|
|
537
|
+
# agregado `release` y POST /events solo ingiere ámbito slice (slice_id obligatorio,
|
|
538
|
+
# progress_service._apply_and_append) o proyecto: encolarlo era rechazo garantizado al
|
|
539
|
+
# drenar. rc 5 con instrucción de reintentar al reconectar (mismo patrón que propose-asset).
|
|
466
540
|
runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
|
|
467
541
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
468
|
-
rc=0; bash "$RO" verdict R1-mvp smell fail --evidence "3 smells" >/dev/null
|
|
469
|
-
|
|
470
|
-
|
|
542
|
+
rc=0; err="$(bash "$RO" verdict R1-mvp smell fail --evidence "3 smells" 2>&1 >/dev/null)" || rc=$?
|
|
543
|
+
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
544
|
+
[ "$rc" = 5 ] && [ "$n" = 0 ] && echo "$err" | grep -qi "reintenta" \
|
|
545
|
+
&& echo "OK verdict de release offline -> rc 5 sin encolar, con instrucción de reintento" \
|
|
546
|
+
|| { echo "FAIL verdict de release offline (rc=$rc n=$n err=$err)"; fail=1; }
|
|
547
|
+
|
|
548
|
+
# Offline: el reporte de integración de front tampoco se encola [#44] —
|
|
549
|
+
# `front_integration_reported` no existe en el catálogo v2 y su superficie síncrona en el
|
|
550
|
+
# hub es de gobierno humano (`can_govern_front`); un evento encolado jamás entraría.
|
|
551
|
+
rc=0; err="$(bash "$RO" front-integration fr-1 --merge-status merged --resmoke pass --evidence "x" 2>&1 >/dev/null)" || rc=$?
|
|
552
|
+
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
553
|
+
[ "$rc" = 5 ] && [ "$n" = 0 ] && echo "$err" | grep -qi "reintenta" \
|
|
554
|
+
&& echo "OK front-integration offline -> rc 5 sin encolar, con instrucción de reintento" \
|
|
555
|
+
|| { echo "FAIL front-integration offline (rc=$rc n=$n err=$err)"; fail=1; }
|
|
471
556
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
472
557
|
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
|
|
473
558
|
|
|
@@ -493,6 +578,9 @@ rc=0; bash "$SO" gate inventado pass >/dev/null 2>&1 || rc=$?
|
|
|
493
578
|
[ ! -s "$TMP/calls-ga.jsonl" ] && echo "OK gate desconocido no toca la red" || { echo "FAIL gate desconocido llamó al runtime"; fail=1; }
|
|
494
579
|
|
|
495
580
|
# Gate feliz con evidencia por fichero (texto largo, tildes y comillas).
|
|
581
|
+
# [#44] El contrato del hub (VerdictIn y el catálogo GateVerdict) llama `verdict` a la
|
|
582
|
+
# clave y el reducer solo promueve con "PASS"/"FAIL" en MAYÚSCULA (domain.py:423-431):
|
|
583
|
+
# `status:"pass"` era rechazo garantizado en el sync Y en la cola.
|
|
496
584
|
python3 -c "open('$TMP/ev.txt','w').write('suite verde: 42/42 «ñ» \"ok\"\n'*50)"
|
|
497
585
|
rc=0; out="$(bash "$SO" gate tdd pass --evidence-file "$TMP/ev.txt")" || rc=$?
|
|
498
586
|
[ "$rc" = 0 ] && echo "OK gate pass -> rc 0" || { echo "FAIL gate pass rc ($rc)"; fail=1; }
|
|
@@ -503,10 +591,20 @@ for line in open(sys.argv[1]):
|
|
|
503
591
|
c=json.loads(line)
|
|
504
592
|
if c["path"]=="/slices/sl-7/verdicts":
|
|
505
593
|
b=json.loads(c["body"])
|
|
506
|
-
ok = b["gate"]=="tdd" and b
|
|
594
|
+
ok = b["gate"]=="tdd" and b.get("verdict")=="PASS" and "status" not in b and "42/42" in b["evidence"]
|
|
507
595
|
print("SI" if ok else "NO")
|
|
508
596
|
PYX
|
|
509
|
-
)" = SI ] && echo "OK el veredicto viaja con gate/
|
|
597
|
+
)" = SI ] && echo "OK el veredicto viaja con gate/verdict(PASS)/evidence" || { echo "FAIL cuerpo del veredicto"; fail=1; }
|
|
598
|
+
|
|
599
|
+
# `na` no existe en el catálogo del hub (cualquier verdict ≠ PASS/FAIL es bloqueo,
|
|
600
|
+
# invariante 5): en runtime no viaja ni se encola — aviso local y rc 0.
|
|
601
|
+
: > "$TMP/calls-ga.jsonl"
|
|
602
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
603
|
+
rc=0; out="$(bash "$SO" gate fidelity na 2>&1)" || rc=$?
|
|
604
|
+
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
605
|
+
[ "$rc" = 0 ] && [ "$n" = 0 ] && [ ! -s "$TMP/calls-ga.jsonl" ] && echo "$out" | grep -qi "na" \
|
|
606
|
+
&& echo "OK gate na en runtime -> aviso local, sin red y sin encolar (el hub no lo admite)" \
|
|
607
|
+
|| { echo "FAIL gate na (rc=$rc n=$n out=$out)"; fail=1; }
|
|
510
608
|
|
|
511
609
|
# 422: compuerta mecánica del servidor -> rc 6, sin reintento y sin encolar.
|
|
512
610
|
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
@@ -522,12 +620,15 @@ echo "$err" | grep -q "wiring_verified" && echo "OK gate 422 muestra la razón d
|
|
|
522
620
|
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')"
|
|
523
621
|
[ "$n" = 0 ] && echo "OK gate 422 no encola (no se reintenta lo rechazado)" || { echo "FAIL gate 422 encoló ($n)"; fail=1; }
|
|
524
622
|
|
|
525
|
-
# Offline: el veredicto se encola como
|
|
623
|
+
# Offline: el veredicto se encola como gate_verdict (nombre del catálogo v2, #44) y rc 5.
|
|
526
624
|
runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
|
|
527
625
|
rc=0; bash "$SO" gate journey_smoke pass --evidence "smoke verde" >/dev/null 2>&1 || rc=$?
|
|
528
626
|
[ "$rc" = 5 ] && echo "OK gate offline -> rc 5" || { echo "FAIL gate offline rc ($rc)"; fail=1; }
|
|
529
|
-
grep -
|
|
530
|
-
|
|
627
|
+
f="$(grep -l '"type": "gate_verdict"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
628
|
+
[ -n "$f" ] && [ "$(python3 -c "
|
|
629
|
+
import json;p=json.load(open('$f'))['payload']
|
|
630
|
+
print('SI' if p.get('verdict')=='PASS' and 'status' not in p else 'NO')")" = SI ] \
|
|
631
|
+
&& echo "OK gate offline encola gate_verdict con verdict PASS" || { echo "FAIL gate offline no encoló gate_verdict"; fail=1; }
|
|
531
632
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
532
633
|
|
|
533
634
|
# Regresión: revisión final de rama — _ops_post_or_queue (offline, la rama que gate/submit/
|
|
@@ -554,12 +655,32 @@ rc=0; bash "$SO" submit --pr-url "https://example.invalid/pr/1" --openspec-chang
|
|
|
554
655
|
[ "$rc" = 0 ] && echo "OK submit -> rc 0" || { echo "FAIL submit rc ($rc)"; fail=1; }
|
|
555
656
|
grep -q '"path": "/slices/sl-7/submit"' "$TMP/calls-ga.jsonl" && echo "OK submit llama a su endpoint" || { echo "FAIL submit endpoint"; fail=1; }
|
|
556
657
|
|
|
658
|
+
# submit offline: se encola como slice_submitted con payload VACÍO [#44] — el catálogo no
|
|
659
|
+
# admite ningún campo (SliceSubmitted, extra="forbid"); pr_url/openspec_change vivían solo
|
|
660
|
+
# para el 422 del hub.
|
|
661
|
+
runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
|
|
662
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
663
|
+
rc=0; bash "$SO" submit --pr-url "https://example.invalid/pr/1" --openspec-change add-cobros >/dev/null 2>&1 || rc=$?
|
|
664
|
+
f="$(grep -l '"type": "slice_submitted"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
665
|
+
[ "$rc" = 5 ] && [ -n "$f" ] && [ "$(python3 -c "
|
|
666
|
+
import json;d=json.load(open('$f'))
|
|
667
|
+
print('SI' if d['payload']=={} and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
|
|
668
|
+
&& echo "OK submit offline encola slice_submitted con payload {} y slice_id" \
|
|
669
|
+
|| { echo "FAIL submit offline (rc=$rc)"; fail=1; }
|
|
670
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
671
|
+
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
672
|
+
|
|
557
673
|
# archive: SIEMPRE por la cola (opsx:archive corre dentro del PR) y protegido de la cota.
|
|
674
|
+
# [#44] SliceArchived no admite ningún campo (extra="forbid"): payload VACÍO, slice_id
|
|
675
|
+
# de primer nivel — con --epic/--pr-url el hub rechazaba TODO archivado offline.
|
|
558
676
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
559
677
|
rc=0; bash "$SO" archive --epic EP-003 --openspec-change add-cobros --pr-url "https://example.invalid/pr/1" >/dev/null || rc=$?
|
|
560
678
|
[ "$rc" = 0 ] && echo "OK archive -> rc 0" || { echo "FAIL archive rc ($rc)"; fail=1; }
|
|
561
|
-
grep -
|
|
562
|
-
|
|
679
|
+
f="$(grep -l '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
680
|
+
[ -n "$f" ] && [ "$(python3 -c "
|
|
681
|
+
import json;d=json.load(open('$f'))
|
|
682
|
+
print('SI' if d['payload']=={} and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
|
|
683
|
+
&& echo "OK archive encola slice_archived con payload {} y slice_id" || { echo "FAIL archive no encoló el formato del catálogo"; fail=1; }
|
|
563
684
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
564
685
|
|
|
565
686
|
# Regresión: revisión final de rama — si ops_enqueue_from_file falla, archive NUNCA debe
|
|
@@ -587,7 +708,9 @@ for line in open(sys.argv[1]):
|
|
|
587
708
|
c=json.loads(line)
|
|
588
709
|
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
589
710
|
b=json.loads(c["body"])
|
|
590
|
-
|
|
711
|
+
# [#44] el espejo también usa el nombre del catálogo (TIPOS_DE_ESPEJO del hub
|
|
712
|
+
# enumera gate_verdict, no verdict_reported).
|
|
713
|
+
ok = b["kind"]=="gate_verdict" and b["origin"]=="mirror" and b["payload"]["slice_ref"]["epic_code"]=="EP-009"
|
|
591
714
|
print("SI" if ok else "NO")
|
|
592
715
|
PYX
|
|
593
716
|
)" = SI ] && echo "OK dual espeja con la identidad fichero-primaria" || { echo "FAIL espejo dual"; fail=1; }
|
|
@@ -606,7 +729,8 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
|
606
729
|
# fichero es primario) — igual que el resto de cmd_* en dual (ver el bloque de arriba).
|
|
607
730
|
DC="$ROOT/hooks/build/dual-compare.sh"
|
|
608
731
|
mirror_escalated() {
|
|
609
|
-
# SI/NO: ¿calls-ga.jsonl tiene un POST a mirror/transitions con kind=
|
|
732
|
+
# SI/NO: ¿calls-ga.jsonl tiene un POST a mirror/transitions con kind=slice_escalated?
|
|
733
|
+
# [#44] la escalada usa el nombre del catálogo v2 (slice_escalated) también en el espejo.
|
|
610
734
|
python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
611
735
|
import json,sys
|
|
612
736
|
found=False
|
|
@@ -617,7 +741,7 @@ for line in open(sys.argv[1]):
|
|
|
617
741
|
continue
|
|
618
742
|
if c.get("path")=="/projects/p-1/mirror/transitions":
|
|
619
743
|
b=json.loads(c["body"])
|
|
620
|
-
if b.get("kind")=="
|
|
744
|
+
if b.get("kind")=="slice_escalated":
|
|
621
745
|
found=True
|
|
622
746
|
print("SI" if found else "NO")
|
|
623
747
|
PYX
|
|
@@ -728,8 +852,24 @@ f="$(grep -l '"type": "wiring_checklist_seeded"' "$PROJ/.claude/state/outbox/"*.
|
|
|
728
852
|
&& echo "OK wiring seed encola los 300 items en un evento" || { echo "FAIL wiring seed payload"; fail=1; }
|
|
729
853
|
[ "$(python3 -c "import json;print(json.load(open('$f')).get('slice_id'))")" = "sl-7" ] \
|
|
730
854
|
&& echo "OK wiring seed lleva slice_id" || { echo "FAIL wiring seed sin slice_id"; fail=1; }
|
|
855
|
+
# [#44] WiringChecklistItem del catálogo es {item_id, kind, ref} EXACTO (extra="forbid"):
|
|
856
|
+
# `status` no viaja (cada ítem nace failing en el fold del hub) y kind/ref nunca son null.
|
|
857
|
+
[ "$(python3 -c "
|
|
858
|
+
import json
|
|
859
|
+
items=json.load(open('$f'))['payload']['items']
|
|
860
|
+
ok = all(sorted(i.keys())==['item_id','kind','ref'] and i['kind'] is not None and i['ref'] is not None for i in items)
|
|
861
|
+
print('SI' if ok else 'NO')")" = SI ] \
|
|
862
|
+
&& echo "OK wiring seed emite items {item_id,kind,ref} sin status ni nulls (catálogo v2)" \
|
|
863
|
+
|| { echo "FAIL wiring seed items fuera del schema del catálogo"; fail=1; }
|
|
731
864
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
732
865
|
|
|
866
|
+
# Checklist sin items válidos: rechazo LOCAL (el hub exige min_length=1), sin encolar.
|
|
867
|
+
echo '[]' > "$TMP/items-vacio.json"
|
|
868
|
+
rc=0; bash "$SO" wiring seed --file "$TMP/items-vacio.json" >/dev/null 2>&1 || rc=$?
|
|
869
|
+
[ "$rc" = 6 ] && [ "$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')" = 0 ] \
|
|
870
|
+
&& echo "OK wiring seed vacío -> rc 6 sin encolar (el hub rechaza items vacíos)" \
|
|
871
|
+
|| { echo "FAIL wiring seed vacío (rc=$rc)"; fail=1; }
|
|
872
|
+
|
|
733
873
|
# Regresión: revisión final de rama — outbox no escribible -> wiring seed no reporta éxito.
|
|
734
874
|
python3 -c "import json;json.dump([{'item_id':'w0','kind':'ac','ref':'HU-01'}], open('$TMP/items-small.json','w'))"
|
|
735
875
|
chmod 555 "$PROJ/.claude/state/outbox"
|
|
@@ -767,11 +907,17 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
|
|
|
767
907
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
768
908
|
|
|
769
909
|
# progress: nota por argumento y por fichero; tipo EVICTABLE (no protegido).
|
|
910
|
+
# [#44] El catálogo v2 lo llama `progress_noted` (payload {note}, sin cambios de claves).
|
|
770
911
|
rc=0; bash "$SO" progress "cableado el paso 2 del journey" >/dev/null || rc=$?
|
|
771
912
|
[ "$rc" = 0 ] && echo "OK progress -> rc 0" || { echo "FAIL progress rc ($rc)"; fail=1; }
|
|
913
|
+
f="$(grep -l '"type": "progress_noted"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
914
|
+
[ -n "$f" ] && [ "$(python3 -c "
|
|
915
|
+
import json;d=json.load(open('$f'))
|
|
916
|
+
print('SI' if set(d['payload'].keys())=={'note'} and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
|
|
917
|
+
&& echo "OK progress encola progress_noted {note} con slice_id" || { echo "FAIL progress no encoló progress_noted"; fail=1; }
|
|
772
918
|
case " $RUNTIME_OUTBOX_PROTECTED " in
|
|
773
|
-
*"
|
|
774
|
-
*) echo "OK
|
|
919
|
+
*" progress_noted "*) echo "FAIL progress_noted no debe estar protegido"; fail=1 ;;
|
|
920
|
+
*) echo "OK progress_noted es evictable" ;;
|
|
775
921
|
esac
|
|
776
922
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
777
923
|
|
|
@@ -786,6 +932,8 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
|
|
|
786
932
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
787
933
|
|
|
788
934
|
# checkpoint: POST con rama y commit reales del repo del PROYECTO.
|
|
935
|
+
# [#44] La nota viaja como `summary` (CheckpointIn del hub; `note` se descartaba en
|
|
936
|
+
# silencio en el sync y era rechazo por extra="forbid" en la cola).
|
|
789
937
|
: > "$TMP/calls-ga.jsonl"
|
|
790
938
|
rc=0; bash "$SO" checkpoint --note "parada por contexto crítico" >/dev/null || rc=$?
|
|
791
939
|
[ "$rc" = 0 ] && echo "OK checkpoint -> rc 0" || { echo "FAIL checkpoint rc ($rc)"; fail=1; }
|
|
@@ -796,30 +944,387 @@ for line in open(sys.argv[1]):
|
|
|
796
944
|
c=json.loads(line)
|
|
797
945
|
if c["path"]=="/checkpoints":
|
|
798
946
|
b=json.loads(c["body"])
|
|
799
|
-
ok = b.get("slice_id")=="sl-7" and "note" in b and "branch" in b and "commit_sha" in b
|
|
947
|
+
ok = b.get("slice_id")=="sl-7" and "summary" in b and "note" not in b and "branch" in b and "commit_sha" in b
|
|
800
948
|
print("SI" if ok else "NO")
|
|
801
949
|
PYX
|
|
802
|
-
)" = SI ] && echo "OK el checkpoint viaja con slice/branch/commit/
|
|
950
|
+
)" = SI ] && echo "OK el checkpoint viaja con slice/branch/commit/summary" || { echo "FAIL cuerpo del checkpoint"; fail=1; }
|
|
803
951
|
|
|
804
|
-
# checkpoint offline: se encola como
|
|
952
|
+
# checkpoint offline: se encola como checkpoint_recorded (nombre del catálogo, protegido),
|
|
953
|
+
# con el payload EXACTO del catálogo {branch, commit_sha, summary} — el slice_id va de
|
|
954
|
+
# primer nivel en el evento, jamás dentro del payload (extra="forbid").
|
|
805
955
|
runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
|
|
806
956
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
807
957
|
rc=0; bash "$SO" checkpoint --note "sin red" >/dev/null 2>&1 || rc=$?
|
|
808
958
|
[ "$rc" = 5 ] && echo "OK checkpoint offline -> rc 5" || { echo "FAIL checkpoint offline rc ($rc)"; fail=1; }
|
|
809
|
-
grep -
|
|
810
|
-
|
|
959
|
+
f="$(grep -l '"type": "checkpoint_recorded"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
960
|
+
[ -n "$f" ] && [ "$(python3 -c "
|
|
961
|
+
import json;d=json.load(open('$f'))
|
|
962
|
+
ok = set(d['payload'].keys())<=set(['branch','commit_sha','summary']) and 'slice_id' not in d['payload'] and d.get('slice_id')=='sl-7'
|
|
963
|
+
print('SI' if ok else 'NO')")" = SI ] \
|
|
964
|
+
&& echo "OK checkpoint offline encola checkpoint_recorded {branch,commit_sha,summary}" \
|
|
965
|
+
|| { echo "FAIL checkpoint offline no encoló el formato del catálogo"; fail=1; }
|
|
966
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
967
|
+
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
968
|
+
|
|
969
|
+
# ══════════ runtime_dispatch_outbox: sobre {"events":…}, event_type y 4xx [#37] ══════════
|
|
970
|
+
# El hub exige EventBatchIn {"events":[EventIn]} y EventIn.event_type: el lote pelado con
|
|
971
|
+
# la clave "type" era 422 en CADA intento y la cola no drenaba jamás (reintento eterno,
|
|
972
|
+
# el usuario creía que reportó y el hub nunca recibió nada).
|
|
973
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
974
|
+
rm -rf "$PROJ/.claude/state/outbox/rejected"
|
|
975
|
+
runtime_enqueue_event slice_archived '{"epic_code":"EP-003","openspec_change":"add-cobros","pr_url":""}' sl-7
|
|
976
|
+
# Compat: fichero YA encolado por un consumidor con la 0.10.0 (clave interna "type").
|
|
977
|
+
cat > "$PROJ/.claude/state/outbox/00000000000000000000-compat.json" <<'JSON'
|
|
978
|
+
{"client_event_id": "cid-compat-1", "type": "telemetry_gap",
|
|
979
|
+
"payload": {"dropped": 1, "window_h": 1.0, "reason": "compat"},
|
|
980
|
+
"enqueued_at": "2026-08-27T00:00:00.000000Z"}
|
|
981
|
+
JSON
|
|
982
|
+
cid_new="$(python3 -c "
|
|
983
|
+
import json,glob
|
|
984
|
+
fs=[p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if 'compat' not in p and not p.endswith('.dispatch-state.json')]
|
|
985
|
+
print(json.load(open(fs[0]))['client_event_id'])")"
|
|
986
|
+
python3 - "$TMP/routes-ga.json" "$cid_new" <<'PYX'
|
|
987
|
+
import json,sys
|
|
988
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
989
|
+
# Ack con la forma REAL del hub (task_router.post_events): accepted/duplicates/rejected.
|
|
990
|
+
d["POST /events"]={"status":200,"body":{"accepted":[sys.argv[2]],"duplicates":["cid-compat-1"],"rejected":[]}}
|
|
991
|
+
json.dump(d,open(p,"w"))
|
|
992
|
+
PYX
|
|
993
|
+
: > "$TMP/calls-ga.jsonl"
|
|
994
|
+
rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
|
|
995
|
+
[ "$rc" = 0 ] && echo "OK dispatch 200 -> rc 0" || { echo "FAIL dispatch 200 rc ($rc)"; fail=1; }
|
|
996
|
+
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
997
|
+
import json,sys
|
|
998
|
+
ok=False
|
|
999
|
+
for line in open(sys.argv[1]):
|
|
1000
|
+
c=json.loads(line)
|
|
1001
|
+
if c["path"]=="/events":
|
|
1002
|
+
b=json.loads(c["body"])
|
|
1003
|
+
evs=b.get("events") if isinstance(b,dict) else None
|
|
1004
|
+
# [#44] slice_archived encolado por un consumidor 0.10.x con {epic_code,…}: la capa
|
|
1005
|
+
# de compat del despacho lo vacía (el catálogo no admite ningún campo).
|
|
1006
|
+
ok = isinstance(evs,list) and len(evs)==2 \
|
|
1007
|
+
and all("event_type" in e and "type" not in e for e in evs) \
|
|
1008
|
+
and {e["event_type"] for e in evs}=={"slice_archived","telemetry_gap"} \
|
|
1009
|
+
and any(e.get("slice_id")=="sl-7" for e in evs) \
|
|
1010
|
+
and all(e["payload"]=={} for e in evs if e["event_type"]=="slice_archived")
|
|
1011
|
+
print("SI" if ok else "NO")
|
|
1012
|
+
PYX
|
|
1013
|
+
)" = SI ] && echo "OK el lote viaja como {\"events\":[…]} con event_type y payloads del catálogo (compat 0.10.x)" \
|
|
1014
|
+
|| { echo "FAIL sobre/clave del lote"; fail=1; }
|
|
1015
|
+
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
1016
|
+
[ "$n" = 0 ] && echo "OK la cola drena con el ack real del hub (accepted+duplicates)" || { echo "FAIL la cola no drenó ($n)"; fail=1; }
|
|
1017
|
+
|
|
1018
|
+
# Elemento rechazado DENTRO de un 200 (fuera de catálogo/payload inválido): se aparta a
|
|
1019
|
+
# rejected/ con su razón — reenviarlo es inútil; el resto del protocolo sigue fail-open.
|
|
1020
|
+
runtime_enqueue_event verdict_reported '{"gate":"tdd","status":"pass"}' sl-7
|
|
1021
|
+
cid_bad="$(python3 -c "
|
|
1022
|
+
import json,glob
|
|
1023
|
+
fs=[p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json')]
|
|
1024
|
+
print(json.load(open(fs[0]))['client_event_id'])")"
|
|
1025
|
+
python3 - "$TMP/routes-ga.json" "$cid_bad" <<'PYX'
|
|
1026
|
+
import json,sys
|
|
1027
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
1028
|
+
d["POST /events"]={"status":200,"body":{"accepted":[],"duplicates":[],
|
|
1029
|
+
"rejected":[{"client_event_id":sys.argv[2],"reason":"event_type desconocido para el catálogo v2"}]}}
|
|
1030
|
+
json.dump(d,open(p,"w"))
|
|
1031
|
+
PYX
|
|
1032
|
+
: > "$TMP/calls-ga.jsonl"
|
|
1033
|
+
rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
|
|
1034
|
+
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
1035
|
+
nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
1036
|
+
[ "$n" = 0 ] && [ "$nr" = 1 ] \
|
|
1037
|
+
&& echo "OK el elemento rechazado por el hub se aparta a rejected/ (no se reenvía)" \
|
|
1038
|
+
|| { echo "FAIL elemento rechazado (pendientes=$n rechazados=$nr)"; fail=1; }
|
|
1039
|
+
# [#44] El fichero 0.10.x `verdict_reported {gate, status}` viajó TRADUCIDO al catálogo v2
|
|
1040
|
+
# (gate_verdict {gate, verdict: PASS}) — la traducción vive en el único punto de salida.
|
|
1041
|
+
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
1042
|
+
import json,sys
|
|
1043
|
+
ok=False
|
|
1044
|
+
for line in open(sys.argv[1]):
|
|
1045
|
+
c=json.loads(line)
|
|
1046
|
+
if c["path"]=="/events":
|
|
1047
|
+
evs=json.loads(c["body"]).get("events") or []
|
|
1048
|
+
ok = len(evs)==1 and evs[0]["event_type"]=="gate_verdict" \
|
|
1049
|
+
and evs[0]["payload"].get("verdict")=="PASS" and "status" not in evs[0]["payload"]
|
|
1050
|
+
print("SI" if ok else "NO")
|
|
1051
|
+
PYX
|
|
1052
|
+
)" = SI ] && echo "OK verdict_reported 0.10.x drena traducido a gate_verdict {verdict: PASS}" \
|
|
1053
|
+
|| { echo "FAIL traducción de verdict_reported en el despacho"; fail=1; }
|
|
1054
|
+
rm -rf "$PROJ/.claude/state/outbox/rejected"
|
|
1055
|
+
|
|
1056
|
+
# [#44] Renombres de compat 0.10.x en el despacho: checkpoint_created, escalation_raised y
|
|
1057
|
+
# progress_note_recorded drenan con el nombre Y el payload del catálogo v2 sin migración
|
|
1058
|
+
# (note→summary + slice_id fuera del payload; reason/gate/phase→cause; note se conserva).
|
|
1059
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1060
|
+
runtime_enqueue_event checkpoint_created '{"slice_id":"sl-7","branch":"feature/x","commit_sha":"abc123","note":"parada"}'
|
|
1061
|
+
runtime_enqueue_event escalation_raised '{"reason":"bloqueo del AC-2","gate":"tdd","phase":"red"}' sl-7
|
|
1062
|
+
runtime_enqueue_event progress_note_recorded '{"note":"avance"}' sl-7
|
|
1063
|
+
cids_all="$(python3 -c "
|
|
1064
|
+
import json,glob
|
|
1065
|
+
fs=[p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json')]
|
|
1066
|
+
print(json.dumps([json.load(open(p))['client_event_id'] for p in fs]))")"
|
|
1067
|
+
python3 - "$TMP/routes-ga.json" "$cids_all" <<'PYX'
|
|
1068
|
+
import json,sys
|
|
1069
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
1070
|
+
d["POST /events"]={"status":200,"body":{"accepted":json.loads(sys.argv[2]),"duplicates":[],"rejected":[]}}
|
|
1071
|
+
json.dump(d,open(p,"w"))
|
|
1072
|
+
PYX
|
|
1073
|
+
: > "$TMP/calls-ga.jsonl"
|
|
1074
|
+
rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
|
|
1075
|
+
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
1076
|
+
import json,sys
|
|
1077
|
+
ok=False
|
|
1078
|
+
for line in open(sys.argv[1]):
|
|
1079
|
+
c=json.loads(line)
|
|
1080
|
+
if c["path"]=="/events":
|
|
1081
|
+
evs={e["event_type"]:e for e in json.loads(c["body"]).get("events") or []}
|
|
1082
|
+
cp=evs.get("checkpoint_recorded"); es=evs.get("slice_escalated"); pn=evs.get("progress_noted")
|
|
1083
|
+
ok = cp is not None and es is not None and pn is not None \
|
|
1084
|
+
and cp["payload"]=={"branch":"feature/x","commit_sha":"abc123","summary":"parada"} \
|
|
1085
|
+
and cp.get("slice_id")=="sl-7" \
|
|
1086
|
+
and set(es["payload"].keys())=={"cause"} and "bloqueo del AC-2" in es["payload"]["cause"] \
|
|
1087
|
+
and "tdd" in es["payload"]["cause"] \
|
|
1088
|
+
and pn["payload"]=={"note":"avance"}
|
|
1089
|
+
print("SI" if ok else "NO")
|
|
1090
|
+
PYX
|
|
1091
|
+
)" = SI ] && echo "OK los 4 renombres 0.10.x drenan con nombre y payload del catálogo v2" \
|
|
1092
|
+
|| { echo "FAIL renombres de compat en el despacho"; fail=1; }
|
|
1093
|
+
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
1094
|
+
[ "$n" = 0 ] && echo "OK la cola 0.10.x drena entera tras la traducción" || { echo "FAIL cola 0.10.x no drenó ($n)"; fail=1; }
|
|
1095
|
+
|
|
1096
|
+
# [#44] Tipos SIN equivalente en el catálogo (tool_use_recorded, branch_drift,
|
|
1097
|
+
# front_integration_reported): el despacho los aparta LOCALMENTE a rejected/ sin gastar
|
|
1098
|
+
# red — mandarlos era rechazo garantizado. Si solo había de esos, no hay POST.
|
|
1099
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1100
|
+
runtime_enqueue_event tool_use_recorded '{"tool":"Edit","path":"src/a.ts"}' sl-7
|
|
1101
|
+
runtime_enqueue_event branch_drift '{"real":"main","expected":"feature/x"}' sl-7
|
|
1102
|
+
runtime_enqueue_event front_integration_reported '{"merge_status":"merged"}'
|
|
1103
|
+
: > "$TMP/calls-ga.jsonl"
|
|
1104
|
+
rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
|
|
1105
|
+
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
1106
|
+
nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
1107
|
+
[ "$rc" = 0 ] && [ "$n" = 0 ] && [ "$nr" = 3 ] && ! grep -q '"path": "/events"' "$TMP/calls-ga.jsonl" \
|
|
1108
|
+
&& echo "OK los tipos sin equivalente se apartan localmente a rejected/ sin tocar la red" \
|
|
1109
|
+
|| { echo "FAIL pre-filtro de tipos sin equivalente (rc=$rc pendientes=$n rechazados=$nr)"; fail=1; }
|
|
1110
|
+
rm -rf "$PROJ/.claude/state/outbox/rejected"
|
|
1111
|
+
|
|
1112
|
+
# [#44] telemetry_gap de la evicción con el payload del catálogo: {dropped, window_h,
|
|
1113
|
+
# reason} (TelemetryGap: dropped>=1, window_h>0, reason<=2000) — el {dropped_count,
|
|
1114
|
+
# dropped_types} de 0.10.x era rechazo garantizado.
|
|
1115
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1116
|
+
old_ev="$PROJ/.claude/state/outbox/00000000000000000001-viejo.json"
|
|
1117
|
+
python3 -c "import json;json.dump({'client_event_id':'ev-viejo-1','type':'progress_noted','payload':{'note':'x'},'enqueued_at':'2020-01-01T00:00:00Z'},open('$old_ev','w'))"
|
|
1118
|
+
touch -t 202001010000 "$old_ev"
|
|
1119
|
+
runtime_outbox_enforce_cap >/dev/null
|
|
1120
|
+
f="$(grep -l '"type": "telemetry_gap"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
|
|
1121
|
+
[ -n "$f" ] && [ "$(python3 -c "
|
|
1122
|
+
import json;p=json.load(open('$f'))['payload']
|
|
1123
|
+
ok = sorted(p.keys())==['dropped','reason','window_h'] and p['dropped']>=1 and p['window_h']==72.0 \
|
|
1124
|
+
and isinstance(p['reason'],str) and 'progress_noted' in p['reason'] and len(p['reason'])<=2000
|
|
1125
|
+
print('SI' if ok else 'NO')")" = SI ] \
|
|
1126
|
+
&& echo "OK la evicción emite telemetry_gap {dropped, window_h, reason} del catálogo v2" \
|
|
1127
|
+
|| { echo "FAIL payload del telemetry_gap de evicción"; fail=1; }
|
|
1128
|
+
# Coalescer: una segunda evicción actualiza el MISMO gap acumulando dropped.
|
|
1129
|
+
old_ev2="$PROJ/.claude/state/outbox/00000000000000000002-viejo.json"
|
|
1130
|
+
python3 -c "import json;json.dump({'client_event_id':'ev-viejo-2','type':'tool_use_recorded','payload':{},'enqueued_at':'2020-01-01T00:00:00Z'},open('$old_ev2','w'))"
|
|
1131
|
+
touch -t 202001010000 "$old_ev2"
|
|
1132
|
+
runtime_outbox_enforce_cap >/dev/null
|
|
1133
|
+
ngaps="$(grep -l '"type": "telemetry_gap"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | wc -l | tr -d ' ')"
|
|
1134
|
+
[ "$ngaps" = 1 ] && [ "$(python3 -c "
|
|
1135
|
+
import json;p=json.load(open('$f'))['payload']
|
|
1136
|
+
print('SI' if p['dropped']>=2 and sorted(p.keys())==['dropped','reason','window_h'] else 'NO')")" = SI ] \
|
|
1137
|
+
&& echo "OK el coalescer acumula en un único telemetry_gap con el payload del catálogo" \
|
|
1138
|
+
|| { echo "FAIL coalescer del telemetry_gap"; fail=1; }
|
|
811
1139
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
1140
|
+
|
|
1141
|
+
# 4xx del LOTE (p.ej. 422 por contrato roto): NO reintentable — se loggea el cuerpo, el
|
|
1142
|
+
# lote se mueve a rejected/ y se avisa; nada de reintento eterno indistinguible de red caída.
|
|
1143
|
+
runtime_enqueue_event slice_archived '{"epic_code":"EP-004","openspec_change":"","pr_url":""}'
|
|
1144
|
+
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
1145
|
+
import json,sys
|
|
1146
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
1147
|
+
d["POST /events"]={"status":422,"body":{"detail":[{"type":"dict_type","loc":["body","events"]}]}}
|
|
1148
|
+
json.dump(d,open(p,"w"))
|
|
1149
|
+
PYX
|
|
1150
|
+
rm -f "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1151
|
+
rc=0; err="$(runtime_dispatch_outbox 2>&1 >/dev/null)" || rc=$?
|
|
1152
|
+
[ "$rc" != 0 ] && echo "OK dispatch 4xx -> rc != 0" || { echo "FAIL dispatch 4xx rc ($rc)"; fail=1; }
|
|
1153
|
+
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
1154
|
+
nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
1155
|
+
[ "$n" = 0 ] && [ "$nr" = 1 ] && echo "OK el lote 4xx se mueve a rejected/ y no se reintenta" \
|
|
1156
|
+
|| { echo "FAIL lote 4xx (pendientes=$n rechazados=$nr)"; fail=1; }
|
|
1157
|
+
echo "$err" | grep -q "dict_type" && echo "OK el 4xx loggea el cuerpo del error del hub" \
|
|
1158
|
+
|| { echo "FAIL 4xx sin cuerpo del error ($err)"; fail=1; }
|
|
1159
|
+
[ ! -f "$PROJ/.claude/state/outbox/.dispatch-state.json" ] \
|
|
1160
|
+
&& echo "OK el 4xx no agenda backoff (no hay nada que reintentar)" || { echo "FAIL 4xx agendó backoff"; fail=1; }
|
|
1161
|
+
: > "$TMP/calls-ga.jsonl"
|
|
1162
|
+
rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
|
|
1163
|
+
grep -q '"path": "/events"' "$TMP/calls-ga.jsonl" && { echo "FAIL dispatch reintentó un lote 4xx"; fail=1; } \
|
|
1164
|
+
|| echo "OK tras el 4xx la cola queda vacía (sin reintento)"
|
|
1165
|
+
rm -rf "$PROJ/.claude/state/outbox/rejected"
|
|
1166
|
+
|
|
1167
|
+
# 5xx y 429 SIGUEN siendo fail-open: la cola se conserva y el backoff se agenda.
|
|
1168
|
+
runtime_enqueue_event slice_archived '{"epic_code":"EP-005","openspec_change":"","pr_url":""}'
|
|
1169
|
+
for st in 500 429; do
|
|
1170
|
+
python3 - "$TMP/routes-ga.json" "$st" <<'PYX'
|
|
1171
|
+
import json,sys
|
|
1172
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
1173
|
+
d["POST /events"]={"status":int(sys.argv[2]),"body":{"detail":"transitorio"}}
|
|
1174
|
+
json.dump(d,open(p,"w"))
|
|
1175
|
+
PYX
|
|
1176
|
+
rm -f "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1177
|
+
rc=0; runtime_dispatch_outbox >/dev/null 2>&1 || rc=$?
|
|
1178
|
+
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
1179
|
+
[ "$rc" = 1 ] && [ "$n" = 1 ] && [ -f "$PROJ/.claude/state/outbox/.dispatch-state.json" ] \
|
|
1180
|
+
&& echo "OK $st conserva la cola y agenda backoff (fail-open intacto)" \
|
|
1181
|
+
|| { echo "FAIL $st (rc=$rc pendientes=$n)"; fail=1; }
|
|
1182
|
+
done
|
|
1183
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1184
|
+
|
|
1185
|
+
# ══════════════ [#44] anti-drift: todo tipo encolable pertenece al catálogo v2 ══════════════
|
|
1186
|
+
# Catálogo de REFERENCIA del test: réplica literal de `event_catalog.py` del hub
|
|
1187
|
+
# (trycore-ia-hub, backend/app/modules/orchestrator/event_catalog.py, catálogo v2,
|
|
1188
|
+
# verificado el 2026-08-28). Claves admitidas por schema (extra="forbid" en el hub; el
|
|
1189
|
+
# campo `source` lo estampa el SERVIDOR en la ingesta espejo y el cliente jamás lo manda).
|
|
1190
|
+
# Ámbito: `slice` exige slice_id de primer nivel; `project` lo PROHÍBE
|
|
1191
|
+
# (progress_service._apply_and_append + TIPOS_POR_AGREGADO["project"]).
|
|
1192
|
+
# Si mañana un productor encola un tipo o una clave nueva sin pasar por aquí, este
|
|
1193
|
+
# bloque debe ponerse rojo: actualiza el catálogo de referencia SOLO tras verificar
|
|
1194
|
+
# el catálogo real del hub.
|
|
1195
|
+
cat > "$TMP/catalogo-v2.py" <<'PYCAT'
|
|
1196
|
+
CATALOGO = {
|
|
1197
|
+
"gate_verdict": {"claves": {"gate", "verdict", "evidence", "cause", "details_ref"},
|
|
1198
|
+
"obligatorias": {"gate", "verdict"}, "ambito": "slice"},
|
|
1199
|
+
"checkpoint_recorded": {"claves": {"branch", "commit_sha", "summary"},
|
|
1200
|
+
"obligatorias": {"branch", "commit_sha"}, "ambito": "slice"},
|
|
1201
|
+
"progress_noted": {"claves": {"note"}, "obligatorias": {"note"}, "ambito": "slice"},
|
|
1202
|
+
"handoff_recorded": {"claves": {"note", "resume_hint", "stopped_at"},
|
|
1203
|
+
"obligatorias": {"note"}, "ambito": "slice"},
|
|
1204
|
+
"slice_escalated": {"claves": {"cause"}, "obligatorias": {"cause"}, "ambito": "slice"},
|
|
1205
|
+
"slice_submitted": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
|
|
1206
|
+
"slice_archived": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
|
|
1207
|
+
"wiring_checklist_seeded": {"claves": {"items"}, "obligatorias": {"items"}, "ambito": "slice"},
|
|
1208
|
+
"wiring_item_updated": {"claves": {"item_id", "status", "evidence", "kind", "ref"},
|
|
1209
|
+
"obligatorias": {"item_id", "status"}, "ambito": "slice"},
|
|
1210
|
+
"project_fact_updated": {"claves": {"fact", "value", "source"},
|
|
1211
|
+
"obligatorias": {"fact", "source"}, "ambito": "project"},
|
|
1212
|
+
"telemetry_gap": {"claves": {"dropped", "window_h", "reason"},
|
|
1213
|
+
"obligatorias": {"dropped", "window_h", "reason"}, "ambito": "project"},
|
|
1214
|
+
}
|
|
1215
|
+
PYCAT
|
|
1216
|
+
|
|
1217
|
+
# (a) ESTÁTICO: los tipos literales de TODOS los call sites que encolan (hooks/build/**)
|
|
1218
|
+
# están en el catálogo de referencia — un productor nuevo con tipo fuera de catálogo
|
|
1219
|
+
# pone esto rojo aunque nadie le escriba caso dinámico.
|
|
1220
|
+
[ "$(python3 - "$TMP/catalogo-v2.py" "$ROOT/hooks/build" <<'PYX'
|
|
1221
|
+
import re,sys,glob,os
|
|
1222
|
+
ns={}
|
|
1223
|
+
exec(open(sys.argv[1]).read(), ns)
|
|
1224
|
+
catalogo=ns["CATALOGO"]
|
|
1225
|
+
pat=re.compile(r'(?:runtime_enqueue_event|ops_enqueue_from_file|_ops_emit_event)\s+"?([a-z][a-z_]+)"?')
|
|
1226
|
+
pat_pq=re.compile(r'"\$payload"\s+([a-z][a-z_]+)')
|
|
1227
|
+
encontrados=set()
|
|
1228
|
+
for path in glob.glob(os.path.join(sys.argv[2],"**","*.sh"), recursive=True):
|
|
1229
|
+
for line in open(path, encoding="utf-8"):
|
|
1230
|
+
if line.lstrip().startswith("#"):
|
|
1231
|
+
continue
|
|
1232
|
+
for m in pat.finditer(line):
|
|
1233
|
+
encontrados.add(m.group(1))
|
|
1234
|
+
if "_ops_post_or_queue" in line:
|
|
1235
|
+
m=pat_pq.search(line)
|
|
1236
|
+
if m:
|
|
1237
|
+
encontrados.add(m.group(1))
|
|
1238
|
+
fuera=sorted(t for t in encontrados if t not in catalogo)
|
|
1239
|
+
# El regex debe seguir viendo a los productores conocidos: si deja de encontrarlos,
|
|
1240
|
+
# el guard está ciego y también es un fallo.
|
|
1241
|
+
esperados={"gate_verdict","checkpoint_recorded","progress_noted","slice_escalated",
|
|
1242
|
+
"slice_submitted","slice_archived","wiring_checklist_seeded",
|
|
1243
|
+
"wiring_item_updated","project_fact_updated","handoff_recorded"}
|
|
1244
|
+
ciegos=sorted(esperados-encontrados)
|
|
1245
|
+
if fuera:
|
|
1246
|
+
print("FUERA_DE_CATALOGO:"+",".join(fuera))
|
|
1247
|
+
elif ciegos:
|
|
1248
|
+
print("REGEX_CIEGO:"+",".join(ciegos))
|
|
1249
|
+
else:
|
|
1250
|
+
print("SI")
|
|
1251
|
+
PYX
|
|
1252
|
+
)" = SI ] && echo "OK todos los tipos literales encolados por hooks/build pertenecen al catálogo v2" \
|
|
1253
|
+
|| { echo "FAIL anti-drift estático (tipo fuera de catálogo o guard ciego)"; fail=1; }
|
|
1254
|
+
|
|
1255
|
+
# (b) DINÁMICO: se ejecutan TODOS los productores reales en runtime sin red y se valida
|
|
1256
|
+
# cada evento encolado (tipo en catálogo, claves ⊆ admitidas, obligatorias presentes,
|
|
1257
|
+
# slice_id según el ámbito, items del wiring con la forma exacta).
|
|
1258
|
+
runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
|
|
1259
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1260
|
+
bash "$SO" gate tdd pass --evidence "verde" >/dev/null 2>&1
|
|
1261
|
+
bash "$SO" submit --pr-url "https://example.invalid/pr/9" >/dev/null 2>&1
|
|
1262
|
+
bash "$SO" archive --epic EP-003 >/dev/null 2>&1
|
|
1263
|
+
bash "$SO" wiring seed --file "$TMP/items-small.json" >/dev/null 2>&1
|
|
1264
|
+
bash "$SO" wiring update w1 passing --kind e2e --ref HU-1 --evidence "x" >/dev/null 2>&1
|
|
1265
|
+
bash "$SO" progress "nota de avance" >/dev/null 2>&1
|
|
1266
|
+
bash "$SO" checkpoint --note "corte" >/dev/null 2>&1
|
|
1267
|
+
bash "$SO" escalate "bloqueo" --gate tdd >/dev/null 2>&1
|
|
1268
|
+
bash "$SO" fact harness-phase building >/dev/null 2>&1
|
|
1269
|
+
old_ev="$PROJ/.claude/state/outbox/00000000000000000003-viejo.json"
|
|
1270
|
+
python3 -c "import json;json.dump({'client_event_id':'ev-viejo-3','type':'progress_noted','payload':{'note':'x'},'enqueued_at':'2020-01-01T00:00:00Z'},open('$old_ev','w'))"
|
|
1271
|
+
touch -t 202001010000 "$old_ev"
|
|
1272
|
+
runtime_outbox_enforce_cap >/dev/null
|
|
1273
|
+
res_antidrift="$(python3 - "$TMP/catalogo-v2.py" "$PROJ/.claude/state/outbox" <<'PYX'
|
|
1274
|
+
import json,sys,glob,os
|
|
1275
|
+
ns={}
|
|
1276
|
+
exec(open(sys.argv[1]).read(), ns)
|
|
1277
|
+
catalogo=ns["CATALOGO"]
|
|
1278
|
+
errores=[]
|
|
1279
|
+
vistos=set()
|
|
1280
|
+
for path in sorted(glob.glob(os.path.join(sys.argv[2],"*.json"))):
|
|
1281
|
+
if os.path.basename(path)==".dispatch-state.json":
|
|
1282
|
+
continue
|
|
1283
|
+
d=json.load(open(path))
|
|
1284
|
+
t=d.get("type") or ""
|
|
1285
|
+
p=d.get("payload") or {}
|
|
1286
|
+
vistos.add(t)
|
|
1287
|
+
spec=catalogo.get(t)
|
|
1288
|
+
if spec is None:
|
|
1289
|
+
errores.append("tipo fuera de catálogo: %r" % t); continue
|
|
1290
|
+
extra=set(p)-spec["claves"]
|
|
1291
|
+
faltan=spec["obligatorias"]-set(p)
|
|
1292
|
+
if extra: errores.append("%s: claves fuera de schema %s" % (t, sorted(extra)))
|
|
1293
|
+
if faltan: errores.append("%s: faltan obligatorias %s" % (t, sorted(faltan)))
|
|
1294
|
+
if spec["ambito"]=="slice" and not d.get("slice_id"):
|
|
1295
|
+
errores.append("%s: ámbito slice sin slice_id de primer nivel" % t)
|
|
1296
|
+
if spec["ambito"]=="project" and d.get("slice_id"):
|
|
1297
|
+
errores.append("%s: ámbito proyecto con slice_id (el hub lo rechaza)" % t)
|
|
1298
|
+
if t=="wiring_checklist_seeded":
|
|
1299
|
+
items=p.get("items") or []
|
|
1300
|
+
if not items: errores.append("wiring_checklist_seeded: items vacío (min_length=1)")
|
|
1301
|
+
for it in items:
|
|
1302
|
+
if set(it)!={"item_id","kind","ref"} or any(it[k] is None for k in it):
|
|
1303
|
+
errores.append("wiring_checklist_seeded: item fuera de schema %r" % it); break
|
|
1304
|
+
esperados={"gate_verdict","slice_submitted","slice_archived","wiring_checklist_seeded",
|
|
1305
|
+
"wiring_item_updated","progress_noted","checkpoint_recorded","slice_escalated",
|
|
1306
|
+
"project_fact_updated","telemetry_gap"}
|
|
1307
|
+
faltantes=sorted(esperados-vistos)
|
|
1308
|
+
if faltantes:
|
|
1309
|
+
errores.append("productores sin evento encolado (¿cambió un subcomando?): %s" % faltantes)
|
|
1310
|
+
print("SI" if not errores else "NO: "+" | ".join(errores))
|
|
1311
|
+
PYX
|
|
1312
|
+
)"
|
|
1313
|
+
[ "$res_antidrift" = SI ] \
|
|
1314
|
+
&& echo "OK todos los eventos encolados por los productores reales validan contra el catálogo v2" \
|
|
1315
|
+
|| { echo "FAIL anti-drift dinámico -> $res_antidrift"; fail=1; }
|
|
1316
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
812
1317
|
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
813
1318
|
|
|
814
|
-
# ══════════════ scripts/lib/graph-bundle.py [Task 13] ══════════════
|
|
1319
|
+
# ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
|
|
815
1320
|
GB="$ROOT/scripts/lib/graph-bundle.py"
|
|
816
1321
|
cat > "$TMP/epics.json" <<'JSON'
|
|
817
1322
|
{"project_ref":"acme","epics":[
|
|
818
1323
|
{"code":"EP-002","title":"Cobros","layer":"business","files_scope":["src/pagos/**"],"depends_on":["EP-001"],"stories":[{"id":"HU-011","title":"Cobrar"}]},
|
|
819
1324
|
{"code":"EP-001","title":"Cimiento","layer":"foundational","files_scope":["src/core/**"],"depends_on":[]},
|
|
820
1325
|
{"code":"EP-002","title":"Duplicada","layer":"business"},
|
|
821
|
-
{"code":"EP-003","
|
|
822
|
-
],"release_lines":[{"id":"R1-mvp","epics":["EP-001","EP-002"]}]}
|
|
1326
|
+
{"code":"EP-003","depends_on":["EP-404"]}
|
|
1327
|
+
],"release_lines":[{"id":"R1-mvp","epics":["EP-001","EP-002"]},{"id":"R2","epics":["EP-002"]}]}
|
|
823
1328
|
JSON
|
|
824
1329
|
out="$(python3 "$GB" < "$TMP/epics.json" 2> "$TMP/gb.err")"
|
|
825
1330
|
rc=$?
|
|
@@ -828,8 +1333,23 @@ rc=$?
|
|
|
828
1333
|
&& echo "OK graph-bundle deduplica y ordena determinísticamente" || { echo "FAIL orden/dedup del bundle"; fail=1; }
|
|
829
1334
|
grep -q "EP-002" "$TMP/gb.err" && echo "OK graph-bundle avisa del código duplicado" || { echo "FAIL sin aviso de duplicado"; fail=1; }
|
|
830
1335
|
grep -q "EP-404" "$TMP/gb.err" && echo "OK graph-bundle avisa de la dependencia inexistente" || { echo "FAIL sin aviso de dep inexistente"; fail=1; }
|
|
831
|
-
[ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["kind"],d["bundle_version"])')" = "graph
|
|
832
|
-
&& echo "OK el bundle se autodescribe (kind/bundle_version)" || { echo "FAIL cabecera del bundle"; fail=1; }
|
|
1336
|
+
[ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["kind"],d["bundle_version"])')" = "graph 2" ] \
|
|
1337
|
+
&& echo "OK el bundle se autodescribe (kind/bundle_version 2)" || { echo "FAIL cabecera del bundle"; fail=1; }
|
|
1338
|
+
# ── Formato del hub (issue #41): GraphService.import_graph rechaza TODO el grafo
|
|
1339
|
+
# si layer ∉ {FOUNDATIONAL, BUSINESS}; las historias viajan con `code` (el hub
|
|
1340
|
+
# hace story_in["code"]); la línea de release es un string POR ÉPICA.
|
|
1341
|
+
[ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(",".join(str(e["layer"]) for e in d["epics"]))')" = "FOUNDATIONAL,BUSINESS,None" ] \
|
|
1342
|
+
&& echo "OK layer en MAYÚSCULA (VALID_LAYERS del hub); ausente -> null" || { echo "FAIL layer no está en formato del hub"; fail=1; }
|
|
1343
|
+
[ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);h=d["epics"][1]["stories"][0];print(h.get("code"),h.get("title"),"id" in h)')" = "HU-011 Cobrar False" ] \
|
|
1344
|
+
&& echo "OK las historias llevan code+title (sin id)" || { echo "FAIL historias sin code del hub"; fail=1; }
|
|
1345
|
+
[ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(",".join(str(e.get("release_line")) for e in d["epics"]));print("release_lines" in d)')" = "R1-mvp,R1-mvp,None
|
|
1346
|
+
False" ] \
|
|
1347
|
+
&& echo "OK release_line string por épica (sin release_lines aparte)" || { echo "FAIL release_line por épica"; fail=1; }
|
|
1348
|
+
grep -q "R2" "$TMP/gb.err" && echo "OK avisa de la épica en dos líneas de release (gana la primera)" || { echo "FAIL sin aviso de doble línea"; fail=1; }
|
|
1349
|
+
[ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["epics"][2]["title"])')" = "EP-003" ] \
|
|
1350
|
+
&& grep -q "sin .title." "$TMP/gb.err" \
|
|
1351
|
+
&& echo "OK title ausente cae al código con aviso (el hub lo exige)" || { echo "FAIL fallback de title"; fail=1; }
|
|
1352
|
+
grep -qi "layer" "$TMP/gb.err" && echo "OK avisa del layer ausente (el hub rechazará el grafo entero)" || { echo "FAIL sin aviso de layer"; fail=1; }
|
|
833
1353
|
# El bundle es DETERMINISTA: dos corridas idénticas salvo el sello temporal.
|
|
834
1354
|
out2="$(python3 "$GB" < "$TMP/epics.json" 2>/dev/null)"
|
|
835
1355
|
a="$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);d.pop("generated_at");print(json.dumps(d,sort_keys=True))')"
|