@trycore/spec-build-harness 0.8.5 → 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.
Files changed (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +27 -4
  3. package/INSTALL.md +27 -5
  4. package/METODOLOGIA.md +55 -5
  5. package/README.md +39 -6
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +13 -5
  9. package/agents/build/wiring-adversarial-verifier.md +52 -5
  10. package/commands/build/architect.md +1 -1
  11. package/commands/build/claim.md +46 -0
  12. package/commands/build/escalate.md +36 -0
  13. package/commands/build/front.md +9 -3
  14. package/commands/build/onboard.md +75 -14
  15. package/commands/build/prototype.md +3 -2
  16. package/commands/build/reflect.md +60 -40
  17. package/commands/build/release.md +10 -7
  18. package/commands/build/resume.md +33 -13
  19. package/commands/build/slice.md +32 -27
  20. package/commands/build/status.md +35 -0
  21. package/commands/build/work.md +11 -8
  22. package/config/build-config.template.json +4 -0
  23. package/dist/cli.js +32 -0
  24. package/dist/commands/doctor.js +42 -0
  25. package/dist/commands/init.js +84 -1
  26. package/dist/commands/migrate.js +153 -0
  27. package/dist/commands/status.js +34 -0
  28. package/dist/lib/normalize.js +1123 -0
  29. package/dist/lib/paths.js +6 -0
  30. package/dist/lib/runtime-client.js +196 -0
  31. package/dist/lib/settings-merge.js +3 -3
  32. package/dist/lib/state-bundle.js +150 -0
  33. package/docs/commands.md +25 -8
  34. package/docs/getting-started.md +1 -0
  35. package/docs/hooks.md +114 -27
  36. package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
  37. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  38. package/docs/runtime/protocolo-cliente-runtime.md +120 -35
  39. package/hooks/build/build-gate-check.sh +21 -0
  40. package/hooks/build/context-monitor.sh +82 -15
  41. package/hooks/build/context-sync.sh +192 -0
  42. package/hooks/build/design-source-guard.sh +30 -2
  43. package/hooks/build/dual-compare.sh +92 -0
  44. package/hooks/build/event-emitter.sh +32 -0
  45. package/hooks/build/gitflow-guard.sh +164 -14
  46. package/hooks/build/heartbeat.sh +259 -0
  47. package/hooks/build/lib/agent-context.sh +139 -0
  48. package/hooks/build/lib/config.sh +27 -0
  49. package/hooks/build/lib/projection.sh +71 -0
  50. package/hooks/build/lib/runtime-client.sh +625 -0
  51. package/hooks/build/lib/runtime-ops.sh +227 -0
  52. package/hooks/build/lib/state-io.sh +5 -18
  53. package/hooks/build/load-build-state.sh +64 -2
  54. package/hooks/build/reflect-nudge.sh +15 -0
  55. package/hooks/build/release-gate-nudge.sh +15 -0
  56. package/hooks/build/release-ops.sh +171 -0
  57. package/hooks/build/scaffold-guard.sh +29 -2
  58. package/hooks/build/session-start.sh +103 -0
  59. package/hooks/build/session-stop.sh +22 -0
  60. package/hooks/build/slice-ops.sh +948 -0
  61. package/hooks/build/stack-guard.sh +8 -0
  62. package/hooks/build/statusline-bridge.sh +24 -3
  63. package/hooks/build-harness.json +16 -0
  64. package/package.json +3 -3
  65. package/scripts/check-agnostic.sh +3 -1
  66. package/scripts/check-pack-clean.sh +31 -0
  67. package/scripts/check-runtime-purity.sh +43 -0
  68. package/scripts/denylist.txt +4 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +181 -0
  71. package/scripts/runtime-purity-allow.txt +5 -0
  72. package/scripts/smoke-test.sh +1 -1
  73. package/scripts/tests/lib/http-stub.py +46 -0
  74. package/scripts/tests/test-baseline-verdict.sh +92 -0
  75. package/scripts/tests/test-config.sh +25 -0
  76. package/scripts/tests/test-hooks-runtime.sh +828 -0
  77. package/scripts/tests/test-install.sh +103 -0
  78. package/scripts/tests/test-runtime-client.sh +298 -0
  79. package/scripts/tests/test-schema.sh +29 -1
  80. package/scripts/tests/test-skill-ops.sh +1367 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +55 -21
  83. package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
  84. package/skills/building-a-slice/references/dod.md +12 -3
  85. package/skills/building-a-slice/references/dor.md +3 -2
  86. package/skills/building-a-slice/references/evidence-budget.md +51 -0
  87. package/skills/building-a-slice/references/exploration-fanout.md +1 -1
  88. package/skills/building-a-slice/references/gitflow.md +1 -1
  89. package/skills/building-a-slice/references/regression-baseline.md +67 -0
  90. package/skills/building-a-slice/references/runtime-protocol.md +75 -0
  91. package/skills/building-a-slice/references/state-protocol.md +12 -1
  92. package/skills/building-a-slice/workflows/README.md +7 -3
  93. package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
  94. package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
  95. package/skills/managing-parallel-front/SKILL.md +32 -16
  96. package/skills/openspec-archive-change/SKILL.md +15 -0
  97. package/skills/prototyping-screens/SKILL.md +9 -5
  98. package/skills/releasing-a-version/SKILL.md +26 -16
  99. package/skills/releasing-a-version/references/release-dod.md +7 -5
  100. package/skills/releasing-a-version/workflows/README.md +2 -1
  101. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  102. package/skills/setup-architecture/SKILL.md +4 -2
  103. package/state/README.md +16 -1
  104. package/state/build-state.schema.json +2 -1
  105. package/templates/CLAUDE.md.template +16 -0
  106. package/templates/settings-hooks.template.json +8 -4
  107. package/internal/skills/auditar-arnes/SKILL.md +0 -29
@@ -0,0 +1,1367 @@
1
+ #!/usr/bin/env bash
2
+ # test-skill-ops.sh — operaciones de dominio que conducen las skills [EP-OR-08-C].
3
+ # Cubre lib/runtime-ops.sh, slice-ops.sh, release-ops.sh y scripts/lib/graph-bundle.py
4
+ # contra el stub HTTP local del sub-slice A (scripts/tests/lib/http-stub.py).
5
+ set -uo pipefail
6
+ ROOT="$(git rev-parse --show-toplevel)"
7
+ fail=0
8
+ TMP="$(mktemp -d)"
9
+ PROJ="$TMP/proj"
10
+ mkdir -p "$PROJ/.claude/state" "$PROJ/.claude/config"
11
+ export CLAUDE_PROJECT_DIR="$PROJ"
12
+ export BUILD_CONFIG_FILE="$PROJ/.claude/config/build-config.json"
13
+ echo '{"runtime":{"mode":"runtime","stale_seconds":900}}' > "$BUILD_CONFIG_FILE"
14
+ STUBS=""
15
+ cleanup() {
16
+ for p in $STUBS; do kill "$p" 2>/dev/null; done
17
+ rm -rf "$TMP"
18
+ }
19
+ trap cleanup EXIT
20
+
21
+ freeport() { python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()"; }
22
+
23
+ source "$ROOT/hooks/build/lib/runtime-ops.sh" 2>/dev/null || { echo "FAIL no existe lib/runtime-ops.sh"; exit 1; }
24
+
25
+ # --- constantes del contrato ---
26
+ [ "$OPS_RC_LEGACY" = 3 ] && [ "$OPS_RC_NO_SLICE" = 4 ] && [ "$OPS_RC_OFFLINE" = 5 ] \
27
+ && [ "$OPS_RC_REJECTED" = 6 ] && [ "$OPS_RC_NO_WORK" = 7 ] \
28
+ && echo "OK codigos de salida del contrato" || { echo "FAIL codigos de salida"; fail=1; }
29
+ [ "$(ops_path_verdicts sl-1)" = "/slices/sl-1/verdicts" ] && echo "OK ops_path_verdicts" || { echo "FAIL ops_path_verdicts"; fail=1; }
30
+ [ "$(ops_path_mirror p-1)" = "/projects/p-1/mirror/transitions" ] && echo "OK ops_path_mirror" || { echo "FAIL ops_path_mirror"; fail=1; }
31
+ [ "$(ops_path_front_integration f-1 ak-1)" = "/fronts/f-1/members/ak-1/integration" ] && echo "OK ops_path_front_integration" || { echo "FAIL ops_path_front_integration"; fail=1; }
32
+
33
+ # --- ops_json_str: comillas, saltos de línea y tildes por STDIN (nunca por argv) ---
34
+ got="$(ops_json_str 'una "cita" con ñ y
35
+ salto')"
36
+ echo "$got" | python3 -c 'import json,sys; s=json.loads(sys.stdin.read()); print("SI" if s.count(chr(10))==1 and "ñ" in s and "\"cita\"" in s else "NO")' | grep -q SI \
37
+ && echo "OK ops_json_str escapa comillas/saltos/tildes" || { echo "FAIL ops_json_str ($got)"; fail=1; }
38
+ [ "$(PATH=/var/empty ops_json_str hola)" = '""' ] && echo "OK ops_json_str sin python3 -> cadena vacia" || { echo "FAIL ops_json_str sin python3"; fail=1; }
39
+
40
+ # --- ops_json_str_file: trunca en el tope y nunca lanza ---
41
+ python3 -c "open('$TMP/big.txt','w').write('x'*9000)"
42
+ got="$(ops_json_str_file "$TMP/big.txt" 100)"
43
+ n="$(echo "$got" | python3 -c 'import json,sys; print(len(json.loads(sys.stdin.read())))')"
44
+ [ "$n" -lt 200 ] && echo "OK ops_json_str_file trunca" || { echo "FAIL ops_json_str_file trunca ($n)"; fail=1; }
45
+ [ "$(ops_json_str_file "$TMP/no-existe.txt")" = '""' ] && echo "OK ops_json_str_file fichero ausente" || { echo "FAIL ops_json_str_file ausente"; fail=1; }
46
+
47
+ # --- ops_context_hashes: sha256 EFECTIVO local, no el del lock (detección de drift) ---
48
+ mkdir -p "$PROJ/.claude/config"
49
+ printf 'allow-list-v1' > "$PROJ/.claude/config/stack-allowlist.json"
50
+ runtime_lock_write "sha256:v1" '[{"path":"config/stack-allowlist.json","sha256":"sha-del-servidor"},{"path":"rules/no-existe.md","sha256":"otro"}]'
51
+ got="$(ops_context_hashes)"
52
+ real="$(python3 -c "import hashlib;print(hashlib.sha256(open('$PROJ/.claude/config/stack-allowlist.json','rb').read()).hexdigest())")"
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; }
63
+
64
+ # --- ops_enqueue_from_file: payload por FICHERO (sin límite de ARG_MAX) ---
65
+ python3 -c "
66
+ import json
67
+ json.dump({'items':[{'item_id':'w%d'%i,'kind':'e2e'} for i in range(4000)]}, open('$TMP/payload.json','w'))"
68
+ ops_enqueue_from_file wiring_checklist_seeded "$TMP/payload.json" sl-1
69
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
70
+ [ "$n" = 1 ] && echo "OK ops_enqueue_from_file encola 1 evento grande" || { echo "FAIL ops_enqueue_from_file ($n)"; fail=1; }
71
+ f="$(find "$PROJ/.claude/state/outbox" -name '*.json' | head -1)"
72
+ python3 -c "
73
+ import json,sys
74
+ d=json.load(open('$f'))
75
+ ok = d['type']=='wiring_checklist_seeded' and d.get('slice_id')=='sl-1' and len(d['payload']['items'])==4000
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; }
77
+ rm -f "$PROJ/.claude/state/outbox/"*.json
78
+
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
87
+ case " $RUNTIME_OUTBOX_PROTECTED " in
88
+ *" $t "*) : ;;
89
+ *) echo "FAIL $t no está en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
90
+ esac
91
+ done
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)"
100
+ # Regresión de comportamiento (no de la constante): 6 MB de tool_use + 1 slice_archived.
101
+ runtime_enqueue_event slice_archived '{"epic_code":"EP-001"}' sl-1
102
+ python3 - "$PROJ/.claude/state/outbox" <<'PY'
103
+ import json,os,sys,uuid,datetime
104
+ d=sys.argv[1]
105
+ big="y"*700000
106
+ for i in range(10):
107
+ cid=str(uuid.uuid4())
108
+ ts=datetime.datetime.now(datetime.timezone.utc)
109
+ json.dump({"client_event_id":cid,"type":"tool_use_recorded","payload":{"blob":big},
110
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")},
111
+ open(os.path.join(d,ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json"),"w"))
112
+ PY
113
+ runtime_outbox_enforce_cap >/dev/null
114
+ if grep -lq '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null; then
115
+ echo "OK la cota conserva slice_archived"
116
+ else
117
+ echo "FAIL la cota evictó slice_archived"; fail=1
118
+ fi
119
+ rm -f "$PROJ/.claude/state/outbox/"*.json
120
+
121
+ # --- ops_mirror: sólo tiene sentido con project_id; nunca lanza sin él ---
122
+ ops_mirror gate_reported "$TMP/payload.json"; rc=$?
123
+ [ "$rc" -ne 0 ] && echo "OK ops_mirror sin credenciales -> rc != 0" || { echo "FAIL ops_mirror sin credenciales"; fail=1; }
124
+
125
+ # ══════════════ slice-ops.sh: mode / claim / next-step [Task 2] ══════════════
126
+ SO="$ROOT/hooks/build/slice-ops.sh"
127
+
128
+ # legacy: no-op absoluto, rc 3, y CERO red (curl falso al frente del PATH, subshell aislado).
129
+ mkdir -p "$TMP/fakebin"
130
+ cat > "$TMP/fakebin/curl" <<'FAKE'
131
+ #!/usr/bin/env bash
132
+ echo "$@" >> "$FAKE_CURL_LOG"
133
+ exit 0
134
+ FAKE
135
+ chmod +x "$TMP/fakebin/curl"
136
+ : > "$TMP/curl-legacy.log"
137
+ rc=0
138
+ out="$(bash -c "PATH=\"$TMP/fakebin:\$PATH\" FAKE_CURL_LOG=\"$TMP/curl-legacy.log\" TRYCORE_RUNTIME_MODE=legacy CLAUDE_PROJECT_DIR=\"$PROJ\" BUILD_CONFIG_FILE=\"$BUILD_CONFIG_FILE\" bash \"$SO\" claim")" || rc=$?
139
+ [ "$rc" = 3 ] && echo "OK claim en legacy -> rc 3" || { echo "FAIL claim legacy rc ($rc)"; fail=1; }
140
+ echo "$out" | grep -q LEGACY && echo "OK claim en legacy anuncia LEGACY" || { echo "FAIL claim legacy stdout ($out)"; fail=1; }
141
+ [ ! -s "$TMP/curl-legacy.log" ] && echo "OK claim en legacy no abre socket" || { echo "FAIL claim legacy tocó la red"; fail=1; }
142
+
143
+ # dual: tampoco reclama (el fichero decide el slice — spec §6.1).
144
+ rc=0; out="$(TRYCORE_RUNTIME_MODE=dual bash "$SO" claim)" || rc=$?
145
+ [ "$rc" = 3 ] && echo "OK claim en dual -> rc 3" || { echo "FAIL claim dual rc ($rc)"; fail=1; }
146
+ echo "$out" | grep -q DUAL_NO_CLAIM && echo "OK claim en dual anuncia DUAL_NO_CLAIM" || { echo "FAIL claim dual stdout ($out)"; fail=1; }
147
+
148
+ # runtime: claim feliz -> 200, refresca la caché y rinde el slice.
149
+ PORT_CL="$(freeport)"
150
+ cat > "$TMP/routes-cl.json" <<JSON
151
+ {"POST /tasks/next": {"status": 200, "body": {"slice_id": "sl-7", "epic_code": "EP-003", "epic_title": "Cobros", "phase": "dor", "gates": {"dor": false}, "branch_base": "main", "openspec_change": "add-cobros", "docs_ref": "docs/03-backlog/epicas.md#ep-003", "stories": ["HU-011"], "lease": {"ttl_s": 300, "expires_at": "2026-08-19T12:00:00Z"}, "manifest_hash": "sha256:v1", "checkpoint": null}},
152
+ "GET /agent/context": {"status": 200, "body": {"project": {"scaffold_confirmed": true}, "active_slice": {"slice_id": "sl-7", "epic_code": "EP-003", "phase": "dor", "gates": {"dor": false, "tdd": false}, "branch": "feature/cobros", "wiring_failing": [{"item_id": "w1", "kind": "e2e"}], "resume_hint": "cablear w1"}, "context": {"manifest_hash": "sha256:v1", "version": 4}}},
153
+ "GET /projects/p-1/context/manifest": {"status": 200, "body": {"manifest_hash": "sha256:v1", "files": []}}}
154
+ JSON
155
+ : > "$TMP/calls-cl.jsonl"
156
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_CL" "$TMP/routes-cl.json" "$TMP/calls-cl.jsonl" &
157
+ STUBS="$STUBS $!"
158
+ sleep 0.3
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\"}"
160
+
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=$?
173
+ [ "$rc" = 0 ] && echo "OK claim 200 -> rc 0" || { echo "FAIL claim 200 rc ($rc)"; fail=1; }
174
+ echo "$out" | grep -q "sl-7" && echo "OK claim rinde el slice reclamado" || { echo "FAIL claim render ($out)"; fail=1; }
175
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
176
+ import json,sys
177
+ ok=False
178
+ for line in open(sys.argv[1]):
179
+ c=json.loads(line)
180
+ if c["path"]=="/tasks/next":
181
+ b=json.loads(c["body"])
182
+ # POST siempre: el GET no acepta el reporte de hashes (spec §3.1 rev. 4).
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
186
+ print("SI" if ok else "NO")
187
+ PYX
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; }
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; }
190
+
191
+ # next-step: deriva del cliente (spec §2), prioridad resume_hint > wiring > sub-slices > gate.
192
+ out="$(bash "$SO" next-step)"
193
+ echo "$out" | grep -q "cablear w1" && echo "OK next-step prioriza resume_hint" || { echo "FAIL next-step ($out)"; fail=1; }
194
+
195
+ # 204: sin trabajo.
196
+ python3 - "$TMP/routes-cl.json" <<'PYX'
197
+ import json,sys
198
+ p=sys.argv[1]; d=json.load(open(p))
199
+ d["POST /tasks/next"]={"status":204,"body":{}}
200
+ json.dump(d,open(p,"w"))
201
+ PYX
202
+ rc=0; out="$(bash "$SO" claim)" || rc=$?
203
+ [ "$rc" = 7 ] && echo "OK claim 204 -> rc 7 (NO_WORK)" || { echo "FAIL claim 204 rc ($rc)"; fail=1; }
204
+
205
+ # 409 por CUERPO: contexto irresoluble no se reintenta.
206
+ python3 - "$TMP/routes-cl.json" <<'PYX'
207
+ import json,sys
208
+ p=sys.argv[1]; d=json.load(open(p))
209
+ d["POST /tasks/next"]={"status":409,"body":{"reason":"context_unresolvable","detail":"falta publicar el manifiesto"}}
210
+ json.dump(d,open(p,"w"))
211
+ PYX
212
+ : > "$TMP/calls-cl.jsonl"
213
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
214
+ [ "$rc" = 6 ] && echo "OK 409 context_unresolvable -> rc 6" || { echo "FAIL 409 irresoluble rc ($rc)"; fail=1; }
215
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-cl.jsonl")"
216
+ [ "$n" = 1 ] && echo "OK 409 irresoluble NO se reintenta" || { echo "FAIL se reintentó un 409 irresoluble ($n)"; fail=1; }
217
+
218
+ # 409 por contención: un reintento y, si persiste, NO_WORK.
219
+ python3 - "$TMP/routes-cl.json" <<'PYX'
220
+ import json,sys
221
+ p=sys.argv[1]; d=json.load(open(p))
222
+ d["POST /tasks/next"]={"status":409,"body":{"reason":"contention"}}
223
+ json.dump(d,open(p,"w"))
224
+ PYX
225
+ : > "$TMP/calls-cl.jsonl"
226
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
227
+ [ "$rc" = 7 ] && echo "OK 409 contención agotada -> rc 7" || { echo "FAIL 409 contención rc ($rc)"; fail=1; }
228
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-cl.jsonl")"
229
+ [ "$n" = 2 ] && echo "OK 409 contención reintenta exactamente una vez" || { echo "FAIL reintentos de contención ($n)"; fail=1; }
230
+
231
+ # 409 por DRIFT: el reintento debe refrescar el estado REAL del servidor (GET
232
+ # /agent/context) antes de forzar el resync. Pasar "" a context-sync.sh es un no-op en
233
+ # este escenario: su fallback cae en runtime.credentials.manifest_hash (caché del último
234
+ # /agents/register), que en drift YA coincide con el lock local — eso es justo lo que
235
+ # distingue "drift" del estado real del servidor — y el primer guard de context-sync.sh
236
+ # corta en seco sin llegar a preguntarle nada al servidor (regresión del hallazgo de
237
+ # revisión: 0 llamadas a /agent/context y 0 al manifiesto real).
238
+ python3 - "$TMP/routes-cl.json" <<'PYX'
239
+ import json,sys
240
+ p=sys.argv[1]; d=json.load(open(p))
241
+ d["POST /tasks/next"]={"status":409,"body":{"reason":"drift"}}
242
+ d["GET /agent/context"]["body"]["context"]["manifest_hash"]="sha256:v9"
243
+ d["GET /projects/p-1/context/manifest"]["body"]["manifest_hash"]="sha256:v9"
244
+ json.dump(d,open(p,"w"))
245
+ PYX
246
+ : > "$TMP/calls-cl.jsonl"
247
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
248
+ [ "$rc" = 7 ] && echo "OK 409 drift agotado -> rc 7" || { echo "FAIL 409 drift rc ($rc)"; fail=1; }
249
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-cl.jsonl")"
250
+ [ "$n" = 2 ] && echo "OK 409 drift reintenta exactamente una vez" || { echo "FAIL reintentos de drift ($n)"; fail=1; }
251
+ n_ctx="$(grep -c '"path": "/agent/context"' "$TMP/calls-cl.jsonl")"
252
+ [ "$n_ctx" -ge 1 ] && echo "OK 409 drift refresca /agent/context (no confía en la caché de credenciales)" || { echo "FAIL 409 drift no refrescó /agent/context ($n_ctx)"; fail=1; }
253
+ n_mani="$(grep -c '"path": "/projects/p-1/context/manifest"' "$TMP/calls-cl.jsonl")"
254
+ [ "$n_mani" -ge 1 ] && echo "OK 409 drift fuerza el GET del manifiesto real (el guard de context-sync.sh no corta en seco)" || { echo "FAIL 409 drift no forzó el manifiesto real ($n_mani)"; fail=1; }
255
+ [ "$(runtime_lock_manifest_hash)" = "sha256:v9" ] \
256
+ && echo "OK 409 drift converge el lock al manifiesto real del servidor" || { echo "FAIL el lock no convergió tras el drift"; fail=1; }
257
+
258
+ # 401: token revocado -> rechazo accionable, sin reintento.
259
+ python3 - "$TMP/routes-cl.json" <<'PYX'
260
+ import json,sys
261
+ p=sys.argv[1]; d=json.load(open(p))
262
+ d["POST /tasks/next"]={"status":401,"body":{"error":"invalid_token"}}
263
+ json.dump(d,open(p,"w"))
264
+ PYX
265
+ rc=0; err="$(bash "$SO" claim 2>&1 >/dev/null)" || rc=$?
266
+ [ "$rc" = 6 ] && echo "OK 401 -> rc 6" || { echo "FAIL 401 rc ($rc)"; fail=1; }
267
+ echo "$err" | grep -qi "ADMIN" && echo "OK 401 da mensaje accionable" || { echo "FAIL 401 sin mensaje accionable"; fail=1; }
268
+
269
+ # Runtime caído: OFFLINE, y la caché previa intacta. El puerto 9 (discard) está siempre
270
+ # cerrado en el loopback: no hace falta matar el stub, que sigue sirviendo a otros bloques.
271
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
272
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
273
+ [ "$rc" = 5 ] && echo "OK claim offline -> rc 5" || { echo "FAIL claim offline rc ($rc)"; fail=1; }
274
+ [ "$(projection_get active_slice.slice_id "")" = "sl-7" ] && echo "OK claim offline preserva la caché" || { echo "FAIL la caché se perdió offline"; fail=1; }
275
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
276
+
277
+ # ══════════════ slice-ops.sh: fact / propose-asset [Task 5] ══════════════
278
+ rm -f "$PROJ/.claude/state/outbox/"*.json
279
+
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.
297
+ rc=0; bash "$SO" fact project-kind greenfield --source human >/dev/null || rc=$?
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
+
311
+ echo '[{"id":"homepage","applies":true},{"id":"login-authn","applies":false}]' > "$TMP/checklist.json"
312
+ rc=0; bash "$SO" fact foundation --required true --epic EP-000 --checklist-file "$TMP/checklist.json" >/dev/null || rc=$?
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; }
333
+
334
+ # Valor inválido: rechazo local, sin encolar.
335
+ rm -f "$PROJ/.claude/state/outbox/"*.json
336
+ rc=0; bash "$SO" fact project-kind marciano >/dev/null 2>&1 || rc=$?
337
+ [ "$rc" = 6 ] && [ "$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')" = 0 ] \
338
+ && echo "OK fact con valor inválido -> rc 6 sin encolar" || { echo "FAIL fact inválido ($rc)"; fail=1; }
339
+
340
+ # Regresión: si ops_enqueue_from_file falla (outbox no escribible: permisos, disco lleno,
341
+ # excepción de python3), _fact_emit NUNCA debe reportar éxito -- ni rc 0 ni el mensaje
342
+ # "encolado". Condición REAL (no simulada): quitamos permiso de escritura al directorio
343
+ # outbox y confirmamos que mkdir/tempfile fallan de verdad antes de aserir el rc.
344
+ rm -f "$PROJ/.claude/state/outbox/"*.json
345
+ chmod 555 "$PROJ/.claude/state/outbox"
346
+ rc=0; out="$(bash "$SO" fact project-kind greenfield 2>&1)" || rc=$?
347
+ chmod 755 "$PROJ/.claude/state/outbox"
348
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
349
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'encolado' \
350
+ && echo "OK fact con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
351
+ || { echo "FAIL fact con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
352
+ rm -f "$PROJ/.claude/state/outbox/"*.json
353
+
354
+ # propose-asset: cuerpo {path, type_key, content} contra la superficie de AGENTE.
355
+ # El http-stub sigue usando routes-cl.json, así que agregamos la nueva ruta ahí.
356
+ python3 - "$TMP/routes-cl.json" <<'PYX'
357
+ import json,sys
358
+ p=sys.argv[1]; d=json.load(open(p))
359
+ d["POST /projects/p-1/context/agent-proposals"]={"status":202,"body":{"proposal_id":"pr-1","status":"pending_admin"}}
360
+ json.dump(d,open(p,"w"))
361
+ PYX
362
+ : > "$TMP/calls-cl.jsonl"
363
+ printf -- '---\nstyle: hexagonal\n---\n# Estilo elegido\nHexagonal.\n' > "$TMP/asset.md"
364
+ rc=0; out="$(bash "$SO" propose-asset --type-key arch.style --path "docs/adr/0001-estilo.md" --content-file "$TMP/asset.md")" || rc=$?
365
+ [ "$rc" = 0 ] && echo "OK propose-asset -> rc 0" || { echo "FAIL propose-asset rc ($rc)"; fail=1; }
366
+ echo "$out" | grep -qi "ADMIN" && echo "OK propose-asset recuerda que publica un ADMIN" || { echo "FAIL propose-asset sin aviso de gobierno"; fail=1; }
367
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
368
+ import json,sys
369
+ ok=False
370
+ for line in open(sys.argv[1]):
371
+ c=json.loads(line)
372
+ if c["path"]=="/projects/p-1/context/agent-proposals":
373
+ b=json.loads(c["body"])
374
+ ok = set(b.keys())=={"path","type_key","content"} and "hexagonal" in b["content"]
375
+ print("SI" if ok else "NO")
376
+ PYX
377
+ )" = SI ] && echo "OK propose-asset manda exactamente {path,type_key,content}" || { echo "FAIL cuerpo de propose-asset"; fail=1; }
378
+
379
+ # 422 del schema del tipo: razón visible, sin reintento.
380
+ python3 - "$TMP/routes-cl.json" <<'PYX'
381
+ import json,sys
382
+ p=sys.argv[1]; d=json.load(open(p))
383
+ d["POST /projects/p-1/context/agent-proposals"]={"status":422,"body":{"error":"frontmatter_schema","detail":"falta 'style'"}}
384
+ json.dump(d,open(p,"w"))
385
+ PYX
386
+ rc=0; err="$(bash "$SO" propose-asset --type-key arch.style --path "x.md" --content-file "$TMP/asset.md" 2>&1 >/dev/null)" || rc=$?
387
+ [ "$rc" = 6 ] && echo "$err" | grep -q "style" && echo "OK propose-asset 422 muestra la razón" || { echo "FAIL propose-asset 422 ($rc)"; fail=1; }
388
+ rm -f "$PROJ/.claude/state/outbox/"*.json
389
+
390
+ # ══════════════ slice-ops.sh: status / escalate / usage [Task 6] ══════════════
391
+ out="$(bash "$SO" status --no-refresh)"
392
+ rc=$?
393
+ [ "$rc" = 0 ] && echo "OK status -> rc 0" || { echo "FAIL status rc ($rc)"; fail=1; }
394
+ echo "$out" | grep -q "modo:" && echo "$out" | grep -q "sl-7" && echo "$out" | grep -qi "cola" \
395
+ && echo "OK status informa modo, slice y cola" || { echo "FAIL status incompleto: $out"; fail=1; }
396
+
397
+ # status es informativo: NUNCA falla aunque no haya nada.
398
+ rm -f "$PROJ/.claude/state/runtime-projection.json"
399
+ rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" status --no-refresh)" || rc=$?
400
+ [ "$rc" = 0 ] && echo "OK status en legacy sin caché -> rc 0" || { echo "FAIL status legacy rc ($rc)"; fail=1; }
401
+ agent_context_fetch_and_cache
402
+
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.
406
+ rm -f "$PROJ/.claude/state/outbox/"*.json
407
+ rc=0; out="$(bash "$SO" escalate "el AC-3 contradice el prototipo" --gate fidelity)" || rc=$?
408
+ [ "$rc" = 0 ] && echo "OK escalate -> rc 0" || { echo "FAIL escalate rc ($rc)"; fail=1; }
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; }
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; }
417
+ rm -f "$PROJ/.claude/state/outbox/"*.json
418
+
419
+ # Regresión: si ops_enqueue_from_file falla (outbox no escribible), escalate NUNCA debe
420
+ # reportar éxito -- ni rc 0, ni el mensaje "registrada", ni la nota de gobierno humano
421
+ # (que solo tiene sentido si el evento de verdad quedó registrado). Condición REAL: quitamos
422
+ # permiso de escritura al directorio outbox, igual que en la regresión de `fact` de arriba.
423
+ rm -f "$PROJ/.claude/state/outbox/"*.json
424
+ chmod 555 "$PROJ/.claude/state/outbox"
425
+ rc=0; out="$(bash "$SO" escalate "el AC-3 contradice el prototipo" --gate fidelity 2>&1)" || rc=$?
426
+ chmod 755 "$PROJ/.claude/state/outbox"
427
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
428
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -qi "registrada" \
429
+ && echo "OK escalate con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
430
+ || { echo "FAIL escalate con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
431
+ rm -f "$PROJ/.claude/state/outbox/"*.json
432
+
433
+ # usage: lista los 13 subcomandos REALMENTE implementados (contrato con la prosa de las
434
+ # skills) — el plan queda completo con la Task 4 (wiring/progress/checkpoint).
435
+ usage_out="$(bash "$SO" --help 2>&1)"
436
+ for sub in mode claim next-step gate submit archive wiring progress checkpoint fact propose-asset status escalate; do
437
+ echo "$usage_out" | grep -q -- "$sub" || { echo "FAIL usage no documenta $sub"; fail=1; }
438
+ done
439
+ echo "$usage_out" | grep -qi "pendientes" \
440
+ && { echo "FAIL usage todavía anuncia pendientes tras completar la Task 4"; fail=1; } \
441
+ || echo "OK usage documenta los 13 subcomandos como implementados, sin pendientes"
442
+
443
+ # Regresión de modo LEGACY sobre TODOS los subcomandos IMPLEMENTADOS que transicionan:
444
+ # rc 3 y cero red (los 13, ya con wiring/progress/checkpoint de la Task 4).
445
+ : > "$TMP/curl-legacy.log"
446
+ for sub in "gate tdd pass" "submit" "archive" "wiring update w1 passing" "progress algo" "checkpoint --note algo" "fact project-kind greenfield" "propose-asset --type-key t --path p --content-file $TMP/asset.md" "escalate algo --gate fidelity"; do
447
+ rc=0
448
+ bash -c "PATH=\"$TMP/fakebin:\$PATH\" FAKE_CURL_LOG=\"$TMP/curl-legacy.log\" TRYCORE_RUNTIME_MODE=legacy CLAUDE_PROJECT_DIR=\"$PROJ\" BUILD_CONFIG_FILE=\"$BUILD_CONFIG_FILE\" bash \"$SO\" $sub" >/dev/null 2>&1 || rc=$?
449
+ [ "$rc" = 3 ] || { echo "FAIL «${sub}» en legacy devolvió $rc (esperado 3)"; fail=1; }
450
+ done
451
+ [ ! -s "$TMP/curl-legacy.log" ] && echo "OK los subcomandos implementados no tocan la red en legacy" || { echo "FAIL algún subcomando abrió socket en legacy"; fail=1; }
452
+
453
+ # ══════════════ release-ops.sh [Task 7] ══════════════
454
+ RO="$ROOT/hooks/build/release-ops.sh"
455
+ python3 - "$TMP/routes-cl.json" <<'PYX'
456
+ import json,sys
457
+ p=sys.argv[1]; d=json.load(open(p))
458
+ d["POST /releases/R1-mvp/verdicts"]={"status":200,"body":{"ok":True}}
459
+ d["POST /fronts/fr-1/members/ak-1/integration"]={"status":200,"body":{"ok":True}}
460
+ json.dump(d,open(p,"w"))
461
+ PYX
462
+ : > "$TMP/calls-cl.jsonl"
463
+
464
+ # Gate de release desconocido: rechazo local, sin red.
465
+ rc=0; bash "$RO" verdict R1-mvp tdd pass >/dev/null 2>&1 || rc=$?
466
+ [ "$rc" = 6 ] && echo "OK verdict de release con gate de slice -> rc 6" || { echo "FAIL gate de release inválido ($rc)"; fail=1; }
467
+
468
+ # Regresión: bajo `set -u`, este bash (3.2.57) confunde el límite del nombre de una
469
+ # variable cuando queda pegada sin separador a un carácter multibyte como «»» — "$var»"
470
+ # revienta con "unbound variable" aunque $var esté definida; "${var}»" no. Reproducido
471
+ # de forma aislada antes de corregir (script mínimo: set -u; x=1; echo "«$x»" -> revienta).
472
+ # El mensaje de rechazo debe interpolar el gate real y NO reventar por este bug.
473
+ errout="$(bash -c "PATH=\"$TMP/fakebin:\$PATH\" CLAUDE_PROJECT_DIR=\"$PROJ\" BUILD_CONFIG_FILE=\"$BUILD_CONFIG_FILE\" bash \"$RO\" verdict R1-mvp tdd pass" 2>&1 >/dev/null)"
474
+ echo "$errout" | grep -q "«tdd»" && echo "OK el gate rechazado se interpola literal (no «\$gate» roto)" || { echo "FAIL el mensaje de gate rechazado no interpola: $errout"; fail=1; }
475
+ echo "$errout" | grep -qi "unbound variable" && { echo "FAIL el rechazo de gate revienta por el bug de bash 3.2 en «\$var»"; fail=1; } || echo "OK el rechazo de gate no revienta por el bug de bash 3.2 en «\$var»"
476
+
477
+ # Veredicto feliz: los 6 gates del Release Gate son hechos MEDIDOS (superficie de agente).
478
+ rc=0; bash "$RO" verdict R1-mvp security pass --evidence "0 hallazgos bloqueantes" >/dev/null || rc=$?
479
+ [ "$rc" = 0 ] && echo "OK verdict de release -> rc 0" || { echo "FAIL verdict de release rc ($rc)"; fail=1; }
480
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
481
+ import json,sys
482
+ ok=False
483
+ for line in open(sys.argv[1]):
484
+ c=json.loads(line)
485
+ if c["path"]=="/releases/R1-mvp/verdicts":
486
+ b=json.loads(c["body"])
487
+ ok = b["gate"]=="security" and b["status"]=="pass" and "bloqueantes" in b["evidence"]
488
+ print("SI" if ok else "NO")
489
+ PYX
490
+ )" = SI ] && echo "OK el veredicto de release viaja completo" || { echo "FAIL cuerpo del veredicto de release"; fail=1; }
491
+
492
+ # `na` es legítimo (ux en una release sin UI).
493
+ rc=0; bash "$RO" verdict R1-mvp ux na >/dev/null || rc=$?
494
+ [ "$rc" = 0 ] && echo "OK verdict na" || { echo "FAIL verdict na ($rc)"; fail=1; }
495
+
496
+ # El CIERRE es humano: close-hint no toca la red y lo dice.
497
+ : > "$TMP/calls-cl.jsonl"
498
+ out="$(bash "$RO" close-hint R1-mvp)"
499
+ rc=$?
500
+ [ "$rc" = 0 ] && [ ! -s "$TMP/calls-cl.jsonl" ] && echo "OK close-hint no llama al runtime" || { echo "FAIL close-hint tocó la red ($rc)"; fail=1; }
501
+ echo "$out" | grep -qi "consola" && echo "OK close-hint remite a la consola humana" || { echo "FAIL close-hint sin instrucción de gobierno"; fail=1; }
502
+
503
+ # Regresión: close-hint interpola la línea real (heredoc SIN comillas), no el literal "$line"
504
+ # — con varias releases abiertas en paralelo el hint debe decir a cuál se refiere.
505
+ echo "$out" | grep -q '«R1-mvp»' && echo "OK close-hint interpola la línea real" || { echo "FAIL close-hint no interpola \$line: $out"; fail=1; }
506
+ echo "$out" | grep -q '\$line' && { echo "FAIL close-hint imprime el literal \$line en vez del valor"; fail=1; } || echo "OK close-hint no imprime el literal \$line"
507
+
508
+ # Reporte de integración del miembro del front (cada worktree = agente propio).
509
+ rc=0; bash "$RO" front-integration fr-1 --merge-status merged --resmoke pass --evidence "journey verde tras merge" >/dev/null || rc=$?
510
+ [ "$rc" = 0 ] && echo "OK front-integration -> rc 0" || { echo "FAIL front-integration rc ($rc)"; fail=1; }
511
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
512
+ import json,sys
513
+ ok=False
514
+ for line in open(sys.argv[1]):
515
+ c=json.loads(line)
516
+ if c["path"]=="/fronts/fr-1/members/ak-1/integration":
517
+ b=json.loads(c["body"])
518
+ ok = b["merge_status"]=="merged" and b["re_smoke"]["status"]=="pass"
519
+ print("SI" if ok else "NO")
520
+ PYX
521
+ )" = SI ] && echo "OK el reporte de integración lleva merge_status y re_smoke" || { echo "FAIL cuerpo de front-integration"; fail=1; }
522
+
523
+ # merge_status inválido: rechazo local.
524
+ rc=0; bash "$RO" front-integration fr-1 --merge-status casi >/dev/null 2>&1 || rc=$?
525
+ [ "$rc" = 6 ] && echo "OK merge_status inválido -> rc 6" || { echo "FAIL merge_status inválido ($rc)"; fail=1; }
526
+
527
+ # legacy: no-op sin red en los tres subcomandos.
528
+ : > "$TMP/curl-legacy.log"
529
+ for sub in "verdict R1-mvp security pass" "front-integration fr-1 --merge-status merged"; do
530
+ rc=0
531
+ bash -c "PATH=\"$TMP/fakebin:\$PATH\" FAKE_CURL_LOG=\"$TMP/curl-legacy.log\" TRYCORE_RUNTIME_MODE=legacy CLAUDE_PROJECT_DIR=\"$PROJ\" BUILD_CONFIG_FILE=\"$BUILD_CONFIG_FILE\" bash \"$RO\" $sub" >/dev/null 2>&1 || rc=$?
532
+ [ "$rc" = 3 ] || { echo "FAIL release-ops «${sub}» en legacy devolvió $rc"; fail=1; }
533
+ done
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; }
535
+
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).
540
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
541
+ rm -f "$PROJ/.claude/state/outbox/"*.json
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; }
556
+ rm -f "$PROJ/.claude/state/outbox/"*.json
557
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
558
+
559
+ # ══════════════ slice-ops.sh: gate / submit / archive [Task 3] ══════════════
560
+ PORT_GA="$(freeport)"
561
+ cat > "$TMP/routes-ga.json" <<JSON
562
+ {"POST /slices/sl-7/verdicts": {"status": 200, "body": {"ok": true}},
563
+ "POST /slices/sl-7/submit": {"status": 200, "body": {"ok": true}},
564
+ "GET /agent/context": {"status": 200, "body": {"project": {"scaffold_confirmed": true}, "active_slice": {"slice_id": "sl-7", "phase": "tdd", "gates": {"dor": true, "tdd": false}}, "context": {"manifest_hash": "sha256:v1"}}},
565
+ "POST /projects/p-1/mirror/transitions": {"status": 202, "body": {"accepted": true}}}
566
+ JSON
567
+ : > "$TMP/calls-ga.jsonl"
568
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GA" "$TMP/routes-ga.json" "$TMP/calls-ga.jsonl" &
569
+ STUBS="$STUBS $!"
570
+ sleep 0.3
571
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
572
+ agent_context_fetch_and_cache
573
+
574
+ # Gate desconocido: rechazo LOCAL, sin tocar la red.
575
+ : > "$TMP/calls-ga.jsonl"
576
+ rc=0; bash "$SO" gate inventado pass >/dev/null 2>&1 || rc=$?
577
+ [ "$rc" = 6 ] && echo "OK gate desconocido -> rc 6" || { echo "FAIL gate desconocido rc ($rc)"; fail=1; }
578
+ [ ! -s "$TMP/calls-ga.jsonl" ] && echo "OK gate desconocido no toca la red" || { echo "FAIL gate desconocido llamó al runtime"; fail=1; }
579
+
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.
584
+ python3 -c "open('$TMP/ev.txt','w').write('suite verde: 42/42 «ñ» \"ok\"\n'*50)"
585
+ rc=0; out="$(bash "$SO" gate tdd pass --evidence-file "$TMP/ev.txt")" || rc=$?
586
+ [ "$rc" = 0 ] && echo "OK gate pass -> rc 0" || { echo "FAIL gate pass rc ($rc)"; fail=1; }
587
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
588
+ import json,sys
589
+ ok=False
590
+ for line in open(sys.argv[1]):
591
+ c=json.loads(line)
592
+ if c["path"]=="/slices/sl-7/verdicts":
593
+ b=json.loads(c["body"])
594
+ ok = b["gate"]=="tdd" and b.get("verdict")=="PASS" and "status" not in b and "42/42" in b["evidence"]
595
+ print("SI" if ok else "NO")
596
+ PYX
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; }
608
+
609
+ # 422: compuerta mecánica del servidor -> rc 6, sin reintento y sin encolar.
610
+ python3 - "$TMP/routes-ga.json" <<'PYX'
611
+ import json,sys
612
+ p=sys.argv[1]; d=json.load(open(p))
613
+ d["POST /slices/sl-7/verdicts"]={"status":422,"body":{"error":"illegal_transition","detail":"dod exige wiring_verified"}}
614
+ json.dump(d,open(p,"w"))
615
+ PYX
616
+ rm -f "$PROJ/.claude/state/outbox/"*.json
617
+ rc=0; err="$(bash "$SO" gate dod pass 2>&1 >/dev/null)" || rc=$?
618
+ [ "$rc" = 6 ] && echo "OK gate 422 -> rc 6" || { echo "FAIL gate 422 rc ($rc)"; fail=1; }
619
+ echo "$err" | grep -q "wiring_verified" && echo "OK gate 422 muestra la razón del servidor" || { echo "FAIL gate 422 sin razón"; fail=1; }
620
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')"
621
+ [ "$n" = 0 ] && echo "OK gate 422 no encola (no se reintenta lo rechazado)" || { echo "FAIL gate 422 encoló ($n)"; fail=1; }
622
+
623
+ # Offline: el veredicto se encola como gate_verdict (nombre del catálogo v2, #44) y rc 5.
624
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
625
+ rc=0; bash "$SO" gate journey_smoke pass --evidence "smoke verde" >/dev/null 2>&1 || rc=$?
626
+ [ "$rc" = 5 ] && echo "OK gate offline -> rc 5" || { echo "FAIL gate offline rc ($rc)"; fail=1; }
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; }
632
+ rm -f "$PROJ/.claude/state/outbox/"*.json
633
+
634
+ # Regresión: revisión final de rama — _ops_post_or_queue (offline, la rama que gate/submit/
635
+ # checkpoint comparten) NUNCA debe reportar "encolado" si ops_enqueue_from_file falla de
636
+ # verdad. Red YA inalcanzable (runtime_url apunta a :9 desde el bloque anterior).
637
+ chmod 555 "$PROJ/.claude/state/outbox"
638
+ rc=0; out="$(bash "$SO" gate journey_smoke pass --evidence "x" 2>&1)" || rc=$?
639
+ chmod 755 "$PROJ/.claude/state/outbox"
640
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
641
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'encolado (tipo protegido' \
642
+ && echo "OK gate offline con outbox no escribible -> sin encolar, sin aviso de éxito" \
643
+ || { echo "FAIL gate offline con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
644
+ rm -f "$PROJ/.claude/state/outbox/"*.json
645
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
646
+
647
+ # submit
648
+ python3 - "$TMP/routes-ga.json" <<'PYX'
649
+ import json,sys
650
+ p=sys.argv[1]; d=json.load(open(p))
651
+ d["POST /slices/sl-7/verdicts"]={"status":200,"body":{"ok":True}}
652
+ json.dump(d,open(p,"w"))
653
+ PYX
654
+ rc=0; bash "$SO" submit --pr-url "https://example.invalid/pr/1" --openspec-change add-cobros >/dev/null || rc=$?
655
+ [ "$rc" = 0 ] && echo "OK submit -> rc 0" || { echo "FAIL submit rc ($rc)"; fail=1; }
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; }
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
+
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.
676
+ rm -f "$PROJ/.claude/state/outbox/"*.json
677
+ rc=0; bash "$SO" archive --epic EP-003 --openspec-change add-cobros --pr-url "https://example.invalid/pr/1" >/dev/null || rc=$?
678
+ [ "$rc" = 0 ] && echo "OK archive -> rc 0" || { echo "FAIL archive rc ($rc)"; fail=1; }
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; }
684
+ rm -f "$PROJ/.claude/state/outbox/"*.json
685
+
686
+ # Regresión: revisión final de rama — si ops_enqueue_from_file falla, archive NUNCA debe
687
+ # reportar éxito (era el mismo bug ya corregido en _fact_emit, reintroducido aquí).
688
+ chmod 555 "$PROJ/.claude/state/outbox"
689
+ rc=0; out="$(bash "$SO" archive --epic EP-003 --openspec-change add-cobros 2>&1)" || rc=$?
690
+ chmod 755 "$PROJ/.claude/state/outbox"
691
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
692
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'slice_archived encolado' \
693
+ && echo "OK archive con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
694
+ || { echo "FAIL archive con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
695
+ rm -f "$PROJ/.claude/state/outbox/"*.json
696
+
697
+ # dual: el fichero es primario -> se espeja la transición con la identidad LOCAL.
698
+ cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
699
+ {"version":"1.0.0","active_slice":{"epica":"EP-009","openspec_change":"add-pagos","branch":"feature/pagos","phase":"tdd","gates":{"dor":true}}}
700
+ JSON
701
+ : > "$TMP/calls-ga.jsonl"
702
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$SO" gate tdd pass --evidence "verde" >/dev/null || rc=$?
703
+ [ "$rc" = 0 ] && echo "OK gate en dual -> rc 0" || { echo "FAIL gate dual rc ($rc)"; fail=1; }
704
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
705
+ import json,sys
706
+ ok=False
707
+ for line in open(sys.argv[1]):
708
+ c=json.loads(line)
709
+ if c["path"]=="/projects/p-1/mirror/transitions":
710
+ b=json.loads(c["body"])
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"
714
+ print("SI" if ok else "NO")
715
+ PYX
716
+ )" = SI ] && echo "OK dual espeja con la identidad fichero-primaria" || { echo "FAIL espejo dual"; fail=1; }
717
+ [ ! -s "$PROJ/.claude/state/outbox" ] || true
718
+ 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)"
719
+
720
+ # dual con espejo caído: avisa pero NO bloquea (rc 0).
721
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
722
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$SO" gate tdd pass >/dev/null 2>&1 || rc=$?
723
+ [ "$rc" = 0 ] && echo "OK dual con espejo caído no bloquea" || { echo "FAIL dual espejo caído rc ($rc)"; fail=1; }
724
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
725
+
726
+ # ══════════════ hooks/build/dual-compare.sh [Task E] ══════════════
727
+ # build-state.json de arriba sigue en pie: EP-009, phase tdd, gates {dor:true}.
728
+ # En dual, escalate NO encola: espeja por POST /projects/{id}/mirror/transitions (el
729
+ # fichero es primario) — igual que el resto de cmd_* en dual (ver el bloque de arriba).
730
+ DC="$ROOT/hooks/build/dual-compare.sh"
731
+ mirror_escalated() {
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.
734
+ python3 - "$TMP/calls-ga.jsonl" <<'PYX'
735
+ import json,sys
736
+ found=False
737
+ for line in open(sys.argv[1]):
738
+ try:
739
+ c=json.loads(line)
740
+ except Exception:
741
+ continue
742
+ if c.get("path")=="/projects/p-1/mirror/transitions":
743
+ b=json.loads(c["body"])
744
+ if b.get("kind")=="slice_escalated":
745
+ found=True
746
+ print("SI" if found else "NO")
747
+ PYX
748
+ }
749
+
750
+ # legacy: no toca red ni outbox (fast exit).
751
+ : > "$TMP/calls-ga.jsonl"
752
+ rc=0; TRYCORE_RUNTIME_MODE=legacy bash "$DC" >/dev/null 2>&1 || rc=$?
753
+ [ "$rc" = 0 ] && [ ! -s "$TMP/calls-ga.jsonl" ] \
754
+ && echo "OK dual-compare en legacy no toca red" || { echo "FAIL dual-compare legacy (rc=$rc)"; fail=1; }
755
+
756
+ # runtime (no dual): tampoco corre — el comparador es específico de dual.
757
+ : > "$TMP/calls-ga.jsonl"
758
+ rc=0; TRYCORE_RUNTIME_MODE=runtime bash "$DC" >/dev/null 2>&1 || rc=$?
759
+ [ "$rc" = 0 ] && [ ! -s "$TMP/calls-ga.jsonl" ] && echo "OK dual-compare en runtime no corre (solo aplica a dual)" || { echo "FAIL dual-compare en runtime"; fail=1; }
760
+
761
+ # dual, proyección COINCIDENTE (épica/fase/gates iguales): sin discrepancia, sin escalar.
762
+ python3 - "$TMP/routes-ga.json" <<'PYX'
763
+ import json,sys
764
+ p=sys.argv[1]; d=json.load(open(p))
765
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-009","phase":"tdd","gates":{"dor":True}}}}
766
+ json.dump(d,open(p,"w"))
767
+ PYX
768
+ : > "$TMP/calls-ga.jsonl"
769
+ rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
770
+ [ "$rc" = 0 ] && [ -z "$err" ] && [ "$(mirror_escalated)" = NO ] \
771
+ && echo "OK dual-compare sin discrepancia -> silencioso, sin escalar" || { echo "FAIL dual-compare sin discrepancia (rc=$rc err=$err)"; fail=1; }
772
+
773
+ # dual, épica DISTINTA en el runtime: discrepancia -> escala vía slice-ops.sh escalate (espejo).
774
+ python3 - "$TMP/routes-ga.json" <<'PYX'
775
+ import json,sys
776
+ p=sys.argv[1]; d=json.load(open(p))
777
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-999","phase":"tdd","gates":{"dor":True}}}}
778
+ json.dump(d,open(p,"w"))
779
+ PYX
780
+ : > "$TMP/calls-ga.jsonl"
781
+ rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
782
+ [ "$rc" = 0 ] && [ "$(mirror_escalated)" = SI ] && echo "$err" | grep -qi "discrepancia" \
783
+ && echo "$err" | grep -q "EP-009" && echo "$err" | grep -q "EP-999" \
784
+ && echo "OK dual-compare con épica distinta escala con el detalle de ambos lados" || { echo "FAIL dual-compare épica distinta (rc=$rc err=$err)"; fail=1; }
785
+
786
+ # dual, gate DISTINTO (mismo epic/fase): también cuenta como discrepancia.
787
+ python3 - "$TMP/routes-ga.json" <<'PYX'
788
+ import json,sys
789
+ p=sys.argv[1]; d=json.load(open(p))
790
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-009","phase":"tdd","gates":{"dor":False}}}}
791
+ json.dump(d,open(p,"w"))
792
+ PYX
793
+ : > "$TMP/calls-ga.jsonl"
794
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
795
+ [ "$(mirror_escalated)" = SI ] && echo "OK dual-compare detecta gate distinto (dor: true vs false)" || { echo "FAIL dual-compare gate distinto"; fail=1; }
796
+
797
+ # dual, runtime SIN slice activo (el fichero sí tiene): también es discrepancia.
798
+ python3 - "$TMP/routes-ga.json" <<'PYX'
799
+ import json,sys
800
+ p=sys.argv[1]; d=json.load(open(p))
801
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True}}}
802
+ json.dump(d,open(p,"w"))
803
+ PYX
804
+ : > "$TMP/calls-ga.jsonl"
805
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
806
+ [ "$(mirror_escalated)" = SI ] && echo "OK dual-compare detecta slice ausente en el runtime" || { echo "FAIL dual-compare slice ausente en runtime"; fail=1; }
807
+
808
+ # fail-open: espejo caído (runtime inalcanzable) -> nunca lanza, nunca aborta (rc 0 siempre).
809
+ python3 - "$TMP/routes-ga.json" <<'PYX'
810
+ import json,sys
811
+ p=sys.argv[1]; d=json.load(open(p))
812
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-999","phase":"tdd","gates":{"dor":True}}}}
813
+ json.dump(d,open(p,"w"))
814
+ PYX
815
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
816
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
817
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
818
+ [ "$rc" = 0 ] && echo "OK dual-compare con espejo caído sigue fail-open (rc 0)" || { echo "FAIL dual-compare espejo caído (rc=$rc)"; fail=1; }
819
+
820
+ # sin build-state.json: nada que comparar, silencioso.
821
+ rm -f "$PROJ/.claude/state/build-state.json"
822
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
823
+ [ "$rc" = 0 ] && echo "OK dual-compare sin build-state.json -> no-op" || { echo "FAIL dual-compare sin build-state.json (rc=$rc)"; fail=1; }
824
+
825
+ # Restaura /agent/context y la caché de proyección al estado que esperan los bloques
826
+ # siguientes (slice_id=sl-7): el comparador de arriba mutó routes-ga.json varias veces.
827
+ python3 - "$TMP/routes-ga.json" <<'PYX'
828
+ import json,sys
829
+ p=sys.argv[1]; d=json.load(open(p))
830
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"tdd","gates":{"dor":True,"tdd":False}},"context":{"manifest_hash":"sha256:v1"}}}
831
+ json.dump(d,open(p,"w"))
832
+ PYX
833
+ agent_context_fetch_and_cache
834
+
835
+ # ══════════════ slice-ops.sh: wiring / progress / checkpoint [Task 4] ══════════════
836
+ python3 - "$TMP/routes-ga.json" <<'PYX'
837
+ import json,sys
838
+ p=sys.argv[1]; d=json.load(open(p))
839
+ d["POST /checkpoints"]={"status":200,"body":{"ok":True}}
840
+ json.dump(d,open(p,"w"))
841
+ PYX
842
+ rm -f "$PROJ/.claude/state/outbox/"*.json
843
+
844
+ # wiring seed: un evento con TODOS los items, payload por fichero (sin ARG_MAX).
845
+ python3 -c "
846
+ import json
847
+ json.dump([{'item_id':'w%d'%i,'kind':'ac' if i%2 else 'integration','ref':'HU-0%d'%i} for i in range(300)], open('$TMP/items.json','w'))"
848
+ rc=0; bash "$SO" wiring seed --file "$TMP/items.json" >/dev/null || rc=$?
849
+ [ "$rc" = 0 ] && echo "OK wiring seed -> rc 0" || { echo "FAIL wiring seed rc ($rc)"; fail=1; }
850
+ f="$(grep -l '"type": "wiring_checklist_seeded"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
851
+ [ -n "$f" ] && [ "$(python3 -c "import json;print(len(json.load(open('$f'))['payload']['items']))")" = 300 ] \
852
+ && echo "OK wiring seed encola los 300 items en un evento" || { echo "FAIL wiring seed payload"; fail=1; }
853
+ [ "$(python3 -c "import json;print(json.load(open('$f')).get('slice_id'))")" = "sl-7" ] \
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; }
864
+ rm -f "$PROJ/.claude/state/outbox/"*.json
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
+
873
+ # Regresión: revisión final de rama — outbox no escribible -> wiring seed no reporta éxito.
874
+ python3 -c "import json;json.dump([{'item_id':'w0','kind':'ac','ref':'HU-01'}], open('$TMP/items-small.json','w'))"
875
+ chmod 555 "$PROJ/.claude/state/outbox"
876
+ rc=0; out="$(bash "$SO" wiring seed --file "$TMP/items-small.json" 2>&1)" || rc=$?
877
+ chmod 755 "$PROJ/.claude/state/outbox"
878
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
879
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'checklist de wiring sembrado' \
880
+ && echo "OK wiring seed con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
881
+ || { echo "FAIL wiring seed con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
882
+ rm -f "$PROJ/.claude/state/outbox/"*.json
883
+
884
+ # wiring update: status + kind/ref/evidence (spec §4.1-C amplía el agregado slice).
885
+ rc=0; bash "$SO" wiring update w1 passing --kind e2e --ref HU-011 --evidence "newman 12/12" >/dev/null || rc=$?
886
+ [ "$rc" = 0 ] && echo "OK wiring update -> rc 0" || { echo "FAIL wiring update rc ($rc)"; fail=1; }
887
+ f="$(grep -l '"type": "wiring_item_updated"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
888
+ [ "$(python3 -c "
889
+ import json;d=json.load(open('$f'))['payload']
890
+ print('SI' if d['item_id']=='w1' and d['status']=='passing' and d['kind']=='e2e' and d['ref']=='HU-011' and '12/12' in d['evidence'] else 'NO')")" = SI ] \
891
+ && echo "OK wiring update viaja completo" || { echo "FAIL wiring update payload"; fail=1; }
892
+ # Estado inválido: rechazo local, nada encolado.
893
+ rm -f "$PROJ/.claude/state/outbox/"*.json
894
+ rc=0; bash "$SO" wiring update w1 verde >/dev/null 2>&1 || rc=$?
895
+ [ "$rc" = 6 ] && echo "OK wiring update estado inválido -> rc 6" || { echo "FAIL wiring update inválido rc ($rc)"; fail=1; }
896
+ [ "$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')" = 0 ] \
897
+ && echo "OK wiring update inválido no encola" || { echo "FAIL wiring update inválido encoló"; fail=1; }
898
+
899
+ # Regresión: revisión final de rama — outbox no escribible -> wiring update no reporta éxito.
900
+ chmod 555 "$PROJ/.claude/state/outbox"
901
+ rc=0; out="$(bash "$SO" wiring update w1 passing --evidence "x" 2>&1)" || rc=$?
902
+ chmod 755 "$PROJ/.claude/state/outbox"
903
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
904
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'evento encolado' \
905
+ && echo "OK wiring update con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
906
+ || { echo "FAIL wiring update con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
907
+ rm -f "$PROJ/.claude/state/outbox/"*.json
908
+
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).
911
+ rc=0; bash "$SO" progress "cableado el paso 2 del journey" >/dev/null || rc=$?
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; }
918
+ case " $RUNTIME_OUTBOX_PROTECTED " in
919
+ *" progress_noted "*) echo "FAIL progress_noted no debe estar protegido"; fail=1 ;;
920
+ *) echo "OK progress_noted es evictable" ;;
921
+ esac
922
+ rm -f "$PROJ/.claude/state/outbox/"*.json
923
+
924
+ # Regresión: revisión final de rama — outbox no escribible -> progress no reporta éxito.
925
+ chmod 555 "$PROJ/.claude/state/outbox"
926
+ rc=0; out="$(bash "$SO" progress "nota cualquiera" 2>&1)" || rc=$?
927
+ chmod 755 "$PROJ/.claude/state/outbox"
928
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
929
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'nota de progreso encolada' \
930
+ && echo "OK progress con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
931
+ || { echo "FAIL progress con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
932
+ rm -f "$PROJ/.claude/state/outbox/"*.json
933
+
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).
937
+ : > "$TMP/calls-ga.jsonl"
938
+ rc=0; bash "$SO" checkpoint --note "parada por contexto crítico" >/dev/null || rc=$?
939
+ [ "$rc" = 0 ] && echo "OK checkpoint -> rc 0" || { echo "FAIL checkpoint rc ($rc)"; fail=1; }
940
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
941
+ import json,sys
942
+ ok=False
943
+ for line in open(sys.argv[1]):
944
+ c=json.loads(line)
945
+ if c["path"]=="/checkpoints":
946
+ b=json.loads(c["body"])
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
948
+ print("SI" if ok else "NO")
949
+ PYX
950
+ )" = SI ] && echo "OK el checkpoint viaja con slice/branch/commit/summary" || { echo "FAIL cuerpo del checkpoint"; fail=1; }
951
+
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").
955
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
956
+ rm -f "$PROJ/.claude/state/outbox/"*.json
957
+ rc=0; bash "$SO" checkpoint --note "sin red" >/dev/null 2>&1 || rc=$?
958
+ [ "$rc" = 5 ] && echo "OK checkpoint offline -> rc 5" || { echo "FAIL checkpoint offline rc ($rc)"; fail=1; }
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; }
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"
1317
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
1318
+
1319
+ # ══════════════ scripts/lib/graph-bundle.py [Task 13 · issue #41] ══════════════
1320
+ GB="$ROOT/scripts/lib/graph-bundle.py"
1321
+ cat > "$TMP/epics.json" <<'JSON'
1322
+ {"project_ref":"acme","epics":[
1323
+ {"code":"EP-002","title":"Cobros","layer":"business","files_scope":["src/pagos/**"],"depends_on":["EP-001"],"stories":[{"id":"HU-011","title":"Cobrar"}]},
1324
+ {"code":"EP-001","title":"Cimiento","layer":"foundational","files_scope":["src/core/**"],"depends_on":[]},
1325
+ {"code":"EP-002","title":"Duplicada","layer":"business"},
1326
+ {"code":"EP-003","depends_on":["EP-404"]}
1327
+ ],"release_lines":[{"id":"R1-mvp","epics":["EP-001","EP-002"]},{"id":"R2","epics":["EP-002"]}]}
1328
+ JSON
1329
+ out="$(python3 "$GB" < "$TMP/epics.json" 2> "$TMP/gb.err")"
1330
+ rc=$?
1331
+ [ "$rc" = 0 ] && echo "OK graph-bundle rc 0" || { echo "FAIL graph-bundle rc ($rc)"; fail=1; }
1332
+ [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(",".join(e["code"] for e in d["epics"]))')" = "EP-001,EP-002,EP-003" ] \
1333
+ && echo "OK graph-bundle deduplica y ordena determinísticamente" || { echo "FAIL orden/dedup del bundle"; fail=1; }
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; }
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; }
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; }
1353
+ # El bundle es DETERMINISTA: dos corridas idénticas salvo el sello temporal.
1354
+ out2="$(python3 "$GB" < "$TMP/epics.json" 2>/dev/null)"
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))')"
1356
+ b="$(echo "$out2" | python3 -c 'import json,sys;d=json.load(sys.stdin);d.pop("generated_at");print(json.dumps(d,sort_keys=True))')"
1357
+ [ "$a" = "$b" ] && echo "OK el bundle es determinista" || { echo "FAIL el bundle no es determinista"; fail=1; }
1358
+ # Ciclo: se avisa, no se lanza.
1359
+ echo '{"epics":[{"code":"EP-A","depends_on":["EP-B"]},{"code":"EP-B","depends_on":["EP-A"]}]}' | python3 "$GB" > /dev/null 2> "$TMP/gb2.err"
1360
+ rc=$?
1361
+ [ "$rc" = 0 ] && grep -qi "ciclo" "$TMP/gb2.err" && echo "OK graph-bundle detecta ciclos sin lanzar" || { echo "FAIL ciclos ($rc)"; fail=1; }
1362
+ # Entrada ilegible: rc 1 y mensaje, jamás traceback.
1363
+ echo 'no-json' | python3 "$GB" > /dev/null 2> "$TMP/gb3.err"
1364
+ rc=$?
1365
+ [ "$rc" = 1 ] && ! grep -q "Traceback" "$TMP/gb3.err" && echo "OK graph-bundle con entrada ilegible -> rc 1 limpio" || { echo "FAIL entrada ilegible ($rc)"; fail=1; }
1366
+
1367
+ exit $fail