@trycore/spec-build-harness 0.8.4 → 0.10.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 (113) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +43 -5
  3. package/INSTALL.md +28 -6
  4. package/METODOLOGIA.md +65 -10
  5. package/README.md +41 -7
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +17 -6
  9. package/agents/build/ux-fidelity-reviewer.md +4 -1
  10. package/agents/build/wiring-adversarial-verifier.md +52 -5
  11. package/commands/build/architect.md +1 -1
  12. package/commands/build/claim.md +46 -0
  13. package/commands/build/escalate.md +36 -0
  14. package/commands/build/front.md +9 -3
  15. package/commands/build/onboard.md +63 -14
  16. package/commands/build/prototype.md +23 -0
  17. package/commands/build/reflect.md +60 -40
  18. package/commands/build/release.md +10 -7
  19. package/commands/build/resume.md +33 -13
  20. package/commands/build/slice.md +32 -27
  21. package/commands/build/status.md +35 -0
  22. package/commands/build/work.md +11 -8
  23. package/config/build-config.template.json +4 -0
  24. package/dist/cli.js +22 -0
  25. package/dist/commands/doctor.js +42 -0
  26. package/dist/commands/init.js +84 -1
  27. package/dist/commands/migrate.js +48 -0
  28. package/dist/commands/status.js +34 -0
  29. package/dist/lib/normalize.js +276 -0
  30. package/dist/lib/paths.js +6 -0
  31. package/dist/lib/runtime-client.js +196 -0
  32. package/dist/lib/settings-merge.js +3 -3
  33. package/dist/lib/state-bundle.js +46 -0
  34. package/docs/commands.md +32 -9
  35. package/docs/getting-started.md +2 -1
  36. package/docs/hooks.md +114 -27
  37. package/docs/runtime/guia-modo-dual-y-migracion.md +136 -0
  38. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  39. package/docs/runtime/protocolo-cliente-runtime.md +109 -34
  40. package/hooks/build/build-gate-check.sh +21 -0
  41. package/hooks/build/context-monitor.sh +82 -15
  42. package/hooks/build/context-sync.sh +192 -0
  43. package/hooks/build/design-source-guard.sh +31 -3
  44. package/hooks/build/dual-compare.sh +92 -0
  45. package/hooks/build/event-emitter.sh +75 -0
  46. package/hooks/build/gitflow-guard.sh +164 -14
  47. package/hooks/build/heartbeat.sh +259 -0
  48. package/hooks/build/lib/agent-context.sh +139 -0
  49. package/hooks/build/lib/config.sh +27 -0
  50. package/hooks/build/lib/projection.sh +71 -0
  51. package/hooks/build/lib/runtime-client.sh +465 -0
  52. package/hooks/build/lib/runtime-ops.sh +221 -0
  53. package/hooks/build/lib/state-io.sh +5 -18
  54. package/hooks/build/load-build-state.sh +64 -2
  55. package/hooks/build/reflect-nudge.sh +15 -0
  56. package/hooks/build/release-gate-nudge.sh +15 -0
  57. package/hooks/build/release-ops.sh +164 -0
  58. package/hooks/build/scaffold-guard.sh +29 -2
  59. package/hooks/build/session-start.sh +103 -0
  60. package/hooks/build/session-stop.sh +22 -0
  61. package/hooks/build/slice-ops.sh +877 -0
  62. package/hooks/build/stack-guard.sh +8 -0
  63. package/hooks/build/statusline-bridge.sh +24 -3
  64. package/hooks/build-harness.json +16 -0
  65. package/package.json +3 -3
  66. package/scripts/check-agnostic.sh +3 -1
  67. package/scripts/check-pack-clean.sh +31 -0
  68. package/scripts/check-runtime-purity.sh +43 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +133 -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 +853 -0
  77. package/scripts/tests/test-install.sh +57 -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 +847 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +58 -23
  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 +7 -3
  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 +104 -0
  98. package/skills/prototyping-screens/assets/DESIGN.md.template +55 -0
  99. package/skills/prototyping-screens/assets/manifest.schema.json +70 -0
  100. package/skills/prototyping-screens/assets/screen.template.html +34 -0
  101. package/skills/prototyping-screens/references/aesthetic-directions.md +42 -0
  102. package/skills/prototyping-screens/references/extraction.md +57 -0
  103. package/skills/prototyping-screens/references/self-check.md +40 -0
  104. package/skills/releasing-a-version/SKILL.md +25 -16
  105. package/skills/releasing-a-version/references/release-dod.md +7 -5
  106. package/skills/releasing-a-version/workflows/README.md +2 -1
  107. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  108. package/skills/setup-architecture/SKILL.md +4 -2
  109. package/state/README.md +20 -3
  110. package/state/build-state.schema.json +3 -2
  111. package/templates/CLAUDE.md.template +17 -1
  112. package/templates/settings-hooks.template.json +8 -4
  113. package/internal/skills/auditar-arnes/SKILL.md +0 -29
@@ -0,0 +1,847 @@
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
+ echo "$got" | grep -q "$real" && echo "OK ops_context_hashes reporta el sha local efectivo" || { echo "FAIL ops_context_hashes ($got)"; fail=1; }
54
+ echo "$got" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("SI" if any(x["path"]=="rules/no-existe.md" and x["sha256"] is None for x in d) else "NO")' | grep -q SI \
55
+ && echo "OK ops_context_hashes marca null el fichero ausente" || { echo "FAIL ops_context_hashes ausente"; fail=1; }
56
+ [ "$(PATH=/var/empty ops_context_hashes)" = "[]" ] && echo "OK ops_context_hashes sin python3 -> []" || { echo "FAIL ops_context_hashes sin python3"; fail=1; }
57
+
58
+ # --- ops_enqueue_from_file: payload por FICHERO (sin límite de ARG_MAX) ---
59
+ python3 -c "
60
+ import json
61
+ json.dump({'items':[{'item_id':'w%d'%i,'kind':'e2e'} for i in range(4000)]}, open('$TMP/payload.json','w'))"
62
+ ops_enqueue_from_file wiring_checklist_seeded "$TMP/payload.json" sl-1
63
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
64
+ [ "$n" = 1 ] && echo "OK ops_enqueue_from_file encola 1 evento grande" || { echo "FAIL ops_enqueue_from_file ($n)"; fail=1; }
65
+ f="$(find "$PROJ/.claude/state/outbox" -name '*.json' | head -1)"
66
+ python3 -c "
67
+ import json,sys
68
+ d=json.load(open('$f'))
69
+ ok = d['type']=='wiring_checklist_seeded' and d.get('slice_id')=='sl-1' and len(d['payload']['items'])==4000
70
+ print('SI' if ok else 'NO')" | grep -q SI && echo "OK ops_enqueue_from_file conserva tipo/slice_id/payload" || { echo "FAIL contenido del evento encolado"; fail=1; }
71
+ rm -f "$PROJ/.claude/state/outbox/"*.json
72
+
73
+ # --- lista protegida ampliada: la cota jamás descarta un hecho de dominio ---
74
+ for t in slice_archived wiring_item_updated escalation_raised scaffold_confirmed design_source_declared project_kind_declared foundation_declared harness_phase_changed wiring_checklist_seeded front_integration_reported; do
75
+ case " $RUNTIME_OUTBOX_PROTECTED " in
76
+ *" $t "*) : ;;
77
+ *) echo "FAIL $t no está en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
78
+ esac
79
+ done
80
+ echo "OK lista protegida ampliada (si no hubo FAIL arriba)"
81
+ # Regresión de comportamiento (no de la constante): 6 MB de tool_use + 1 slice_archived.
82
+ runtime_enqueue_event slice_archived '{"epic_code":"EP-001"}' sl-1
83
+ python3 - "$PROJ/.claude/state/outbox" <<'PY'
84
+ import json,os,sys,uuid,datetime
85
+ d=sys.argv[1]
86
+ big="y"*700000
87
+ for i in range(10):
88
+ cid=str(uuid.uuid4())
89
+ ts=datetime.datetime.now(datetime.timezone.utc)
90
+ json.dump({"client_event_id":cid,"type":"tool_use_recorded","payload":{"blob":big},
91
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")},
92
+ open(os.path.join(d,ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json"),"w"))
93
+ PY
94
+ runtime_outbox_enforce_cap >/dev/null
95
+ if grep -lq '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null; then
96
+ echo "OK la cota conserva slice_archived"
97
+ else
98
+ echo "FAIL la cota evictó slice_archived"; fail=1
99
+ fi
100
+ rm -f "$PROJ/.claude/state/outbox/"*.json
101
+
102
+ # --- ops_mirror: sólo tiene sentido con project_id; nunca lanza sin él ---
103
+ ops_mirror gate_reported "$TMP/payload.json"; rc=$?
104
+ [ "$rc" -ne 0 ] && echo "OK ops_mirror sin credenciales -> rc != 0" || { echo "FAIL ops_mirror sin credenciales"; fail=1; }
105
+
106
+ # ══════════════ slice-ops.sh: mode / claim / next-step [Task 2] ══════════════
107
+ SO="$ROOT/hooks/build/slice-ops.sh"
108
+
109
+ # legacy: no-op absoluto, rc 3, y CERO red (curl falso al frente del PATH, subshell aislado).
110
+ mkdir -p "$TMP/fakebin"
111
+ cat > "$TMP/fakebin/curl" <<'FAKE'
112
+ #!/usr/bin/env bash
113
+ echo "$@" >> "$FAKE_CURL_LOG"
114
+ exit 0
115
+ FAKE
116
+ chmod +x "$TMP/fakebin/curl"
117
+ : > "$TMP/curl-legacy.log"
118
+ rc=0
119
+ 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=$?
120
+ [ "$rc" = 3 ] && echo "OK claim en legacy -> rc 3" || { echo "FAIL claim legacy rc ($rc)"; fail=1; }
121
+ echo "$out" | grep -q LEGACY && echo "OK claim en legacy anuncia LEGACY" || { echo "FAIL claim legacy stdout ($out)"; fail=1; }
122
+ [ ! -s "$TMP/curl-legacy.log" ] && echo "OK claim en legacy no abre socket" || { echo "FAIL claim legacy tocó la red"; fail=1; }
123
+
124
+ # dual: tampoco reclama (el fichero decide el slice — spec §6.1).
125
+ rc=0; out="$(TRYCORE_RUNTIME_MODE=dual bash "$SO" claim)" || rc=$?
126
+ [ "$rc" = 3 ] && echo "OK claim en dual -> rc 3" || { echo "FAIL claim dual rc ($rc)"; fail=1; }
127
+ echo "$out" | grep -q DUAL_NO_CLAIM && echo "OK claim en dual anuncia DUAL_NO_CLAIM" || { echo "FAIL claim dual stdout ($out)"; fail=1; }
128
+
129
+ # runtime: claim feliz -> 200, refresca la caché y rinde el slice.
130
+ PORT_CL="$(freeport)"
131
+ cat > "$TMP/routes-cl.json" <<JSON
132
+ {"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}},
133
+ "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}}},
134
+ "GET /projects/p-1/context/manifest": {"status": 200, "body": {"manifest_hash": "sha256:v1", "files": []}}}
135
+ JSON
136
+ : > "$TMP/calls-cl.jsonl"
137
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_CL" "$TMP/routes-cl.json" "$TMP/calls-cl.jsonl" &
138
+ STUBS="$STUBS $!"
139
+ sleep 0.3
140
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\",\"project_token\":\"tok-c\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\",\"manifest_hash\":\"sha256:v1\"}"
141
+
142
+ rc=0; out="$(bash "$SO" claim --epic EP-003)" || rc=$?
143
+ [ "$rc" = 0 ] && echo "OK claim 200 -> rc 0" || { echo "FAIL claim 200 rc ($rc)"; fail=1; }
144
+ echo "$out" | grep -q "sl-7" && echo "OK claim rinde el slice reclamado" || { echo "FAIL claim render ($out)"; fail=1; }
145
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
146
+ import json,sys
147
+ ok=False
148
+ for line in open(sys.argv[1]):
149
+ c=json.loads(line)
150
+ if c["path"]=="/tasks/next":
151
+ b=json.loads(c["body"])
152
+ # POST siempre: el GET no acepta el reporte de hashes (spec §3.1 rev. 4).
153
+ ok = c["method"]=="POST" and isinstance(b.get("context_hashes"), list) and b.get("epic_code")=="EP-003"
154
+ print("SI" if ok else "NO")
155
+ PYX
156
+ )" = SI ] && echo "OK el claim usa POST y reporta context_hashes y la épica" || { echo "FAIL cuerpo/método del claim"; fail=1; }
157
+ [ "$(projection_get active_slice.slice_id "")" = "sl-7" ] && echo "OK el claim refresca la caché de proyección" || { echo "FAIL la caché no se refrescó"; fail=1; }
158
+
159
+ # next-step: deriva del cliente (spec §2), prioridad resume_hint > wiring > sub-slices > gate.
160
+ out="$(bash "$SO" next-step)"
161
+ echo "$out" | grep -q "cablear w1" && echo "OK next-step prioriza resume_hint" || { echo "FAIL next-step ($out)"; fail=1; }
162
+
163
+ # 204: sin trabajo.
164
+ python3 - "$TMP/routes-cl.json" <<'PYX'
165
+ import json,sys
166
+ p=sys.argv[1]; d=json.load(open(p))
167
+ d["POST /tasks/next"]={"status":204,"body":{}}
168
+ json.dump(d,open(p,"w"))
169
+ PYX
170
+ rc=0; out="$(bash "$SO" claim)" || rc=$?
171
+ [ "$rc" = 7 ] && echo "OK claim 204 -> rc 7 (NO_WORK)" || { echo "FAIL claim 204 rc ($rc)"; fail=1; }
172
+
173
+ # 409 por CUERPO: contexto irresoluble no se reintenta.
174
+ python3 - "$TMP/routes-cl.json" <<'PYX'
175
+ import json,sys
176
+ p=sys.argv[1]; d=json.load(open(p))
177
+ d["POST /tasks/next"]={"status":409,"body":{"reason":"context_unresolvable","detail":"falta publicar el manifiesto"}}
178
+ json.dump(d,open(p,"w"))
179
+ PYX
180
+ : > "$TMP/calls-cl.jsonl"
181
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
182
+ [ "$rc" = 6 ] && echo "OK 409 context_unresolvable -> rc 6" || { echo "FAIL 409 irresoluble rc ($rc)"; fail=1; }
183
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-cl.jsonl")"
184
+ [ "$n" = 1 ] && echo "OK 409 irresoluble NO se reintenta" || { echo "FAIL se reintentó un 409 irresoluble ($n)"; fail=1; }
185
+
186
+ # 409 por contención: un reintento y, si persiste, NO_WORK.
187
+ python3 - "$TMP/routes-cl.json" <<'PYX'
188
+ import json,sys
189
+ p=sys.argv[1]; d=json.load(open(p))
190
+ d["POST /tasks/next"]={"status":409,"body":{"reason":"contention"}}
191
+ json.dump(d,open(p,"w"))
192
+ PYX
193
+ : > "$TMP/calls-cl.jsonl"
194
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
195
+ [ "$rc" = 7 ] && echo "OK 409 contención agotada -> rc 7" || { echo "FAIL 409 contención rc ($rc)"; fail=1; }
196
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-cl.jsonl")"
197
+ [ "$n" = 2 ] && echo "OK 409 contención reintenta exactamente una vez" || { echo "FAIL reintentos de contención ($n)"; fail=1; }
198
+
199
+ # 409 por DRIFT: el reintento debe refrescar el estado REAL del servidor (GET
200
+ # /agent/context) antes de forzar el resync. Pasar "" a context-sync.sh es un no-op en
201
+ # este escenario: su fallback cae en runtime.credentials.manifest_hash (caché del último
202
+ # /agents/register), que en drift YA coincide con el lock local — eso es justo lo que
203
+ # distingue "drift" del estado real del servidor — y el primer guard de context-sync.sh
204
+ # corta en seco sin llegar a preguntarle nada al servidor (regresión del hallazgo de
205
+ # revisión: 0 llamadas a /agent/context y 0 al manifiesto real).
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":"drift"}}
210
+ d["GET /agent/context"]["body"]["context"]["manifest_hash"]="sha256:v9"
211
+ d["GET /projects/p-1/context/manifest"]["body"]["manifest_hash"]="sha256:v9"
212
+ json.dump(d,open(p,"w"))
213
+ PYX
214
+ : > "$TMP/calls-cl.jsonl"
215
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
216
+ [ "$rc" = 7 ] && echo "OK 409 drift agotado -> rc 7" || { echo "FAIL 409 drift rc ($rc)"; fail=1; }
217
+ n="$(grep -c '"path": "/tasks/next"' "$TMP/calls-cl.jsonl")"
218
+ [ "$n" = 2 ] && echo "OK 409 drift reintenta exactamente una vez" || { echo "FAIL reintentos de drift ($n)"; fail=1; }
219
+ n_ctx="$(grep -c '"path": "/agent/context"' "$TMP/calls-cl.jsonl")"
220
+ [ "$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; }
221
+ n_mani="$(grep -c '"path": "/projects/p-1/context/manifest"' "$TMP/calls-cl.jsonl")"
222
+ [ "$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; }
223
+ [ "$(runtime_lock_manifest_hash)" = "sha256:v9" ] \
224
+ && echo "OK 409 drift converge el lock al manifiesto real del servidor" || { echo "FAIL el lock no convergió tras el drift"; fail=1; }
225
+
226
+ # 401: token revocado -> rechazo accionable, sin reintento.
227
+ python3 - "$TMP/routes-cl.json" <<'PYX'
228
+ import json,sys
229
+ p=sys.argv[1]; d=json.load(open(p))
230
+ d["POST /tasks/next"]={"status":401,"body":{"error":"invalid_token"}}
231
+ json.dump(d,open(p,"w"))
232
+ PYX
233
+ rc=0; err="$(bash "$SO" claim 2>&1 >/dev/null)" || rc=$?
234
+ [ "$rc" = 6 ] && echo "OK 401 -> rc 6" || { echo "FAIL 401 rc ($rc)"; fail=1; }
235
+ echo "$err" | grep -qi "ADMIN" && echo "OK 401 da mensaje accionable" || { echo "FAIL 401 sin mensaje accionable"; fail=1; }
236
+
237
+ # Runtime caído: OFFLINE, y la caché previa intacta. El puerto 9 (discard) está siempre
238
+ # cerrado en el loopback: no hace falta matar el stub, que sigue sirviendo a otros bloques.
239
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
240
+ rc=0; bash "$SO" claim >/dev/null 2>&1 || rc=$?
241
+ [ "$rc" = 5 ] && echo "OK claim offline -> rc 5" || { echo "FAIL claim offline rc ($rc)"; fail=1; }
242
+ [ "$(projection_get active_slice.slice_id "")" = "sl-7" ] && echo "OK claim offline preserva la caché" || { echo "FAIL la caché se perdió offline"; fail=1; }
243
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
244
+
245
+ # ══════════════ slice-ops.sh: fact / propose-asset [Task 5] ══════════════
246
+ rm -f "$PROJ/.claude/state/outbox/"*.json
247
+
248
+ # Los hechos de proyecto son de ÁMBITO PROYECTO: se encolan SIN slice_id (§3.1 rev. 3).
249
+ rc=0; bash "$SO" fact scaffold-confirmed --by "ana@equipo" --notes "npm run dev arranca vacío" >/dev/null || rc=$?
250
+ [ "$rc" = 0 ] && echo "OK fact scaffold-confirmed -> rc 0" || { echo "FAIL fact scaffold rc ($rc)"; fail=1; }
251
+ f="$(grep -l '"type": "scaffold_confirmed"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
252
+ [ -n "$f" ] && [ "$(python3 -c "import json;print('slice_id' in json.load(open('$f')))")" = False ] \
253
+ && echo "OK el hecho de proyecto va SIN slice_id" || { echo "FAIL scaffold_confirmed con slice_id"; fail=1; }
254
+ [ "$(python3 -c "import json;print(json.load(open('$f'))['payload']['confirmed_by'])")" = "ana@equipo" ] \
255
+ && echo "OK scaffold_confirmed lleva quién confirmó" || { echo "FAIL payload de scaffold_confirmed"; fail=1; }
256
+
257
+ rc=0; bash "$SO" fact design-source --applies true --source "docs/05-prototipo/index.html" --confirmed true --by "ana@equipo" >/dev/null || rc=$?
258
+ [ "$rc" = 0 ] && grep -lq '"type": "design_source_declared"' "$PROJ/.claude/state/outbox/"*.json \
259
+ && echo "OK fact design-source" || { echo "FAIL fact design-source ($rc)"; fail=1; }
260
+ rc=0; bash "$SO" fact project-kind greenfield --source human >/dev/null || rc=$?
261
+ [ "$rc" = 0 ] && grep -lq '"type": "project_kind_declared"' "$PROJ/.claude/state/outbox/"*.json \
262
+ && echo "OK fact project-kind" || { echo "FAIL fact project-kind ($rc)"; fail=1; }
263
+ echo '[{"id":"homepage","applies":true},{"id":"login-authn","applies":false}]' > "$TMP/checklist.json"
264
+ rc=0; bash "$SO" fact foundation --required true --epic EP-000 --checklist-file "$TMP/checklist.json" >/dev/null || rc=$?
265
+ f="$(grep -l '"type": "foundation_declared"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
266
+ [ "$rc" = 0 ] && [ -n "$f" ] && [ "$(python3 -c "import json;print(len(json.load(open('$f'))['payload']['checklist']))")" = 2 ] \
267
+ && echo "OK fact foundation con checklist" || { echo "FAIL fact foundation"; fail=1; }
268
+
269
+ # Valor inválido: rechazo local, sin encolar.
270
+ rm -f "$PROJ/.claude/state/outbox/"*.json
271
+ rc=0; bash "$SO" fact project-kind marciano >/dev/null 2>&1 || rc=$?
272
+ [ "$rc" = 6 ] && [ "$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')" = 0 ] \
273
+ && echo "OK fact con valor inválido -> rc 6 sin encolar" || { echo "FAIL fact inválido ($rc)"; fail=1; }
274
+
275
+ # Regresión: si ops_enqueue_from_file falla (outbox no escribible: permisos, disco lleno,
276
+ # excepción de python3), _fact_emit NUNCA debe reportar éxito -- ni rc 0 ni el mensaje
277
+ # "encolado". Condición REAL (no simulada): quitamos permiso de escritura al directorio
278
+ # outbox y confirmamos que mkdir/tempfile fallan de verdad antes de aserir el rc.
279
+ rm -f "$PROJ/.claude/state/outbox/"*.json
280
+ chmod 555 "$PROJ/.claude/state/outbox"
281
+ rc=0; out="$(bash "$SO" fact scaffold-confirmed --by "ana@equipo" 2>&1)" || rc=$?
282
+ chmod 755 "$PROJ/.claude/state/outbox"
283
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
284
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q '"scaffold_confirmed" encolado' \
285
+ && echo "OK fact con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
286
+ || { echo "FAIL fact con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
287
+ rm -f "$PROJ/.claude/state/outbox/"*.json
288
+
289
+ # propose-asset: cuerpo {path, type_key, content} contra la superficie de AGENTE.
290
+ # El http-stub sigue usando routes-cl.json, así que agregamos la nueva ruta ahí.
291
+ python3 - "$TMP/routes-cl.json" <<'PYX'
292
+ import json,sys
293
+ p=sys.argv[1]; d=json.load(open(p))
294
+ d["POST /projects/p-1/context/agent-proposals"]={"status":202,"body":{"proposal_id":"pr-1","status":"pending_admin"}}
295
+ json.dump(d,open(p,"w"))
296
+ PYX
297
+ : > "$TMP/calls-cl.jsonl"
298
+ printf -- '---\nstyle: hexagonal\n---\n# Estilo elegido\nHexagonal.\n' > "$TMP/asset.md"
299
+ rc=0; out="$(bash "$SO" propose-asset --type-key arch.style --path "docs/adr/0001-estilo.md" --content-file "$TMP/asset.md")" || rc=$?
300
+ [ "$rc" = 0 ] && echo "OK propose-asset -> rc 0" || { echo "FAIL propose-asset rc ($rc)"; fail=1; }
301
+ echo "$out" | grep -qi "ADMIN" && echo "OK propose-asset recuerda que publica un ADMIN" || { echo "FAIL propose-asset sin aviso de gobierno"; fail=1; }
302
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
303
+ import json,sys
304
+ ok=False
305
+ for line in open(sys.argv[1]):
306
+ c=json.loads(line)
307
+ if c["path"]=="/projects/p-1/context/agent-proposals":
308
+ b=json.loads(c["body"])
309
+ ok = set(b.keys())=={"path","type_key","content"} and "hexagonal" in b["content"]
310
+ print("SI" if ok else "NO")
311
+ PYX
312
+ )" = SI ] && echo "OK propose-asset manda exactamente {path,type_key,content}" || { echo "FAIL cuerpo de propose-asset"; fail=1; }
313
+
314
+ # 422 del schema del tipo: razón visible, sin reintento.
315
+ python3 - "$TMP/routes-cl.json" <<'PYX'
316
+ import json,sys
317
+ p=sys.argv[1]; d=json.load(open(p))
318
+ d["POST /projects/p-1/context/agent-proposals"]={"status":422,"body":{"error":"frontmatter_schema","detail":"falta 'style'"}}
319
+ json.dump(d,open(p,"w"))
320
+ PYX
321
+ rc=0; err="$(bash "$SO" propose-asset --type-key arch.style --path "x.md" --content-file "$TMP/asset.md" 2>&1 >/dev/null)" || rc=$?
322
+ [ "$rc" = 6 ] && echo "$err" | grep -q "style" && echo "OK propose-asset 422 muestra la razón" || { echo "FAIL propose-asset 422 ($rc)"; fail=1; }
323
+ rm -f "$PROJ/.claude/state/outbox/"*.json
324
+
325
+ # ══════════════ slice-ops.sh: status / escalate / usage [Task 6] ══════════════
326
+ out="$(bash "$SO" status --no-refresh)"
327
+ rc=$?
328
+ [ "$rc" = 0 ] && echo "OK status -> rc 0" || { echo "FAIL status rc ($rc)"; fail=1; }
329
+ echo "$out" | grep -q "modo:" && echo "$out" | grep -q "sl-7" && echo "$out" | grep -qi "cola" \
330
+ && echo "OK status informa modo, slice y cola" || { echo "FAIL status incompleto: $out"; fail=1; }
331
+
332
+ # status es informativo: NUNCA falla aunque no haya nada.
333
+ rm -f "$PROJ/.claude/state/runtime-projection.json"
334
+ rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" status --no-refresh)" || rc=$?
335
+ [ "$rc" = 0 ] && echo "OK status en legacy sin caché -> rc 0" || { echo "FAIL status legacy rc ($rc)"; fail=1; }
336
+ agent_context_fetch_and_cache
337
+
338
+ # escalate: evento tipado + instrucción de gobierno humano.
339
+ rm -f "$PROJ/.claude/state/outbox/"*.json
340
+ rc=0; out="$(bash "$SO" escalate "el AC-3 contradice el prototipo" --gate fidelity)" || rc=$?
341
+ [ "$rc" = 0 ] && echo "OK escalate -> rc 0" || { echo "FAIL escalate rc ($rc)"; fail=1; }
342
+ f="$(grep -l '"type": "escalation_raised"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
343
+ [ -n "$f" ] && [ "$(python3 -c "import json;d=json.load(open('$f'));print('SI' if d['payload']['gate']=='fidelity' and d.get('slice_id')=='sl-7' else 'NO')")" = SI ] \
344
+ && echo "OK escalate encola con gate y slice_id" || { echo "FAIL payload de escalate"; fail=1; }
345
+ echo "$out" | grep -qi "human" && echo "OK escalate declara que la decisión es humana" || { echo "FAIL escalate sin nota de gobierno"; fail=1; }
346
+ rm -f "$PROJ/.claude/state/outbox/"*.json
347
+
348
+ # Regresión: si ops_enqueue_from_file falla (outbox no escribible), escalate NUNCA debe
349
+ # reportar éxito -- ni rc 0, ni el mensaje "registrada", ni la nota de gobierno humano
350
+ # (que solo tiene sentido si el evento de verdad quedó registrado). Condición REAL: quitamos
351
+ # permiso de escritura al directorio outbox, igual que en la regresión de `fact` de arriba.
352
+ rm -f "$PROJ/.claude/state/outbox/"*.json
353
+ chmod 555 "$PROJ/.claude/state/outbox"
354
+ rc=0; out="$(bash "$SO" escalate "el AC-3 contradice el prototipo" --gate fidelity 2>&1)" || rc=$?
355
+ chmod 755 "$PROJ/.claude/state/outbox"
356
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
357
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -qi "registrada" \
358
+ && echo "OK escalate con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
359
+ || { echo "FAIL escalate con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
360
+ rm -f "$PROJ/.claude/state/outbox/"*.json
361
+
362
+ # usage: lista los 13 subcomandos REALMENTE implementados (contrato con la prosa de las
363
+ # skills) — el plan queda completo con la Task 4 (wiring/progress/checkpoint).
364
+ usage_out="$(bash "$SO" --help 2>&1)"
365
+ for sub in mode claim next-step gate submit archive wiring progress checkpoint fact propose-asset status escalate; do
366
+ echo "$usage_out" | grep -q -- "$sub" || { echo "FAIL usage no documenta $sub"; fail=1; }
367
+ done
368
+ echo "$usage_out" | grep -qi "pendientes" \
369
+ && { echo "FAIL usage todavía anuncia pendientes tras completar la Task 4"; fail=1; } \
370
+ || echo "OK usage documenta los 13 subcomandos como implementados, sin pendientes"
371
+
372
+ # Regresión de modo LEGACY sobre TODOS los subcomandos IMPLEMENTADOS que transicionan:
373
+ # rc 3 y cero red (los 13, ya con wiring/progress/checkpoint de la Task 4).
374
+ : > "$TMP/curl-legacy.log"
375
+ 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
376
+ rc=0
377
+ 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=$?
378
+ [ "$rc" = 3 ] || { echo "FAIL «${sub}» en legacy devolvió $rc (esperado 3)"; fail=1; }
379
+ done
380
+ [ ! -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; }
381
+
382
+ # ══════════════ release-ops.sh [Task 7] ══════════════
383
+ RO="$ROOT/hooks/build/release-ops.sh"
384
+ python3 - "$TMP/routes-cl.json" <<'PYX'
385
+ import json,sys
386
+ p=sys.argv[1]; d=json.load(open(p))
387
+ d["POST /releases/R1-mvp/verdicts"]={"status":200,"body":{"ok":True}}
388
+ d["POST /fronts/fr-1/members/ak-1/integration"]={"status":200,"body":{"ok":True}}
389
+ json.dump(d,open(p,"w"))
390
+ PYX
391
+ : > "$TMP/calls-cl.jsonl"
392
+
393
+ # Gate de release desconocido: rechazo local, sin red.
394
+ rc=0; bash "$RO" verdict R1-mvp tdd pass >/dev/null 2>&1 || rc=$?
395
+ [ "$rc" = 6 ] && echo "OK verdict de release con gate de slice -> rc 6" || { echo "FAIL gate de release inválido ($rc)"; fail=1; }
396
+
397
+ # Regresión: bajo `set -u`, este bash (3.2.57) confunde el límite del nombre de una
398
+ # variable cuando queda pegada sin separador a un carácter multibyte como «»» — "$var»"
399
+ # revienta con "unbound variable" aunque $var esté definida; "${var}»" no. Reproducido
400
+ # de forma aislada antes de corregir (script mínimo: set -u; x=1; echo "«$x»" -> revienta).
401
+ # El mensaje de rechazo debe interpolar el gate real y NO reventar por este bug.
402
+ 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)"
403
+ 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; }
404
+ 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»"
405
+
406
+ # Veredicto feliz: los 6 gates del Release Gate son hechos MEDIDOS (superficie de agente).
407
+ rc=0; bash "$RO" verdict R1-mvp security pass --evidence "0 hallazgos bloqueantes" >/dev/null || rc=$?
408
+ [ "$rc" = 0 ] && echo "OK verdict de release -> rc 0" || { echo "FAIL verdict de release rc ($rc)"; fail=1; }
409
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
410
+ import json,sys
411
+ ok=False
412
+ for line in open(sys.argv[1]):
413
+ c=json.loads(line)
414
+ if c["path"]=="/releases/R1-mvp/verdicts":
415
+ b=json.loads(c["body"])
416
+ ok = b["gate"]=="security" and b["status"]=="pass" and "bloqueantes" in b["evidence"]
417
+ print("SI" if ok else "NO")
418
+ PYX
419
+ )" = SI ] && echo "OK el veredicto de release viaja completo" || { echo "FAIL cuerpo del veredicto de release"; fail=1; }
420
+
421
+ # `na` es legítimo (ux en una release sin UI).
422
+ rc=0; bash "$RO" verdict R1-mvp ux na >/dev/null || rc=$?
423
+ [ "$rc" = 0 ] && echo "OK verdict na" || { echo "FAIL verdict na ($rc)"; fail=1; }
424
+
425
+ # El CIERRE es humano: close-hint no toca la red y lo dice.
426
+ : > "$TMP/calls-cl.jsonl"
427
+ out="$(bash "$RO" close-hint R1-mvp)"
428
+ rc=$?
429
+ [ "$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; }
430
+ 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; }
431
+
432
+ # Regresión: close-hint interpola la línea real (heredoc SIN comillas), no el literal "$line"
433
+ # — con varias releases abiertas en paralelo el hint debe decir a cuál se refiere.
434
+ 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; }
435
+ 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"
436
+
437
+ # Reporte de integración del miembro del front (cada worktree = agente propio).
438
+ rc=0; bash "$RO" front-integration fr-1 --merge-status merged --resmoke pass --evidence "journey verde tras merge" >/dev/null || rc=$?
439
+ [ "$rc" = 0 ] && echo "OK front-integration -> rc 0" || { echo "FAIL front-integration rc ($rc)"; fail=1; }
440
+ [ "$(python3 - "$TMP/calls-cl.jsonl" <<'PYX'
441
+ import json,sys
442
+ ok=False
443
+ for line in open(sys.argv[1]):
444
+ c=json.loads(line)
445
+ if c["path"]=="/fronts/fr-1/members/ak-1/integration":
446
+ b=json.loads(c["body"])
447
+ ok = b["merge_status"]=="merged" and b["re_smoke"]["status"]=="pass"
448
+ print("SI" if ok else "NO")
449
+ PYX
450
+ )" = SI ] && echo "OK el reporte de integración lleva merge_status y re_smoke" || { echo "FAIL cuerpo de front-integration"; fail=1; }
451
+
452
+ # merge_status inválido: rechazo local.
453
+ rc=0; bash "$RO" front-integration fr-1 --merge-status casi >/dev/null 2>&1 || rc=$?
454
+ [ "$rc" = 6 ] && echo "OK merge_status inválido -> rc 6" || { echo "FAIL merge_status inválido ($rc)"; fail=1; }
455
+
456
+ # legacy: no-op sin red en los tres subcomandos.
457
+ : > "$TMP/curl-legacy.log"
458
+ for sub in "verdict R1-mvp security pass" "front-integration fr-1 --merge-status merged"; do
459
+ rc=0
460
+ 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=$?
461
+ [ "$rc" = 3 ] || { echo "FAIL release-ops «${sub}» en legacy devolvió $rc"; fail=1; }
462
+ done
463
+ [ ! -s "$TMP/curl-legacy.log" ] && echo "OK release-ops no toca la red en legacy" || { echo "FAIL release-ops abrió socket en legacy"; fail=1; }
464
+
465
+ # Offline: el veredicto de release se encola (protegido como verdict_reported).
466
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
467
+ rm -f "$PROJ/.claude/state/outbox/"*.json
468
+ rc=0; bash "$RO" verdict R1-mvp smell fail --evidence "3 smells" >/dev/null 2>&1 || rc=$?
469
+ [ "$rc" = 5 ] && grep -lq '"type": "verdict_reported"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
470
+ && echo "OK verdict de release offline se encola" || { echo "FAIL verdict de release offline ($rc)"; fail=1; }
471
+ rm -f "$PROJ/.claude/state/outbox/"*.json
472
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CL\"}"
473
+
474
+ # ══════════════ slice-ops.sh: gate / submit / archive [Task 3] ══════════════
475
+ PORT_GA="$(freeport)"
476
+ cat > "$TMP/routes-ga.json" <<JSON
477
+ {"POST /slices/sl-7/verdicts": {"status": 200, "body": {"ok": true}},
478
+ "POST /slices/sl-7/submit": {"status": 200, "body": {"ok": true}},
479
+ "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"}}},
480
+ "POST /projects/p-1/mirror/transitions": {"status": 202, "body": {"accepted": true}}}
481
+ JSON
482
+ : > "$TMP/calls-ga.jsonl"
483
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_GA" "$TMP/routes-ga.json" "$TMP/calls-ga.jsonl" &
484
+ STUBS="$STUBS $!"
485
+ sleep 0.3
486
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
487
+ agent_context_fetch_and_cache
488
+
489
+ # Gate desconocido: rechazo LOCAL, sin tocar la red.
490
+ : > "$TMP/calls-ga.jsonl"
491
+ rc=0; bash "$SO" gate inventado pass >/dev/null 2>&1 || rc=$?
492
+ [ "$rc" = 6 ] && echo "OK gate desconocido -> rc 6" || { echo "FAIL gate desconocido rc ($rc)"; fail=1; }
493
+ [ ! -s "$TMP/calls-ga.jsonl" ] && echo "OK gate desconocido no toca la red" || { echo "FAIL gate desconocido llamó al runtime"; fail=1; }
494
+
495
+ # Gate feliz con evidencia por fichero (texto largo, tildes y comillas).
496
+ python3 -c "open('$TMP/ev.txt','w').write('suite verde: 42/42 «ñ» \"ok\"\n'*50)"
497
+ rc=0; out="$(bash "$SO" gate tdd pass --evidence-file "$TMP/ev.txt")" || rc=$?
498
+ [ "$rc" = 0 ] && echo "OK gate pass -> rc 0" || { echo "FAIL gate pass rc ($rc)"; fail=1; }
499
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
500
+ import json,sys
501
+ ok=False
502
+ for line in open(sys.argv[1]):
503
+ c=json.loads(line)
504
+ if c["path"]=="/slices/sl-7/verdicts":
505
+ b=json.loads(c["body"])
506
+ ok = b["gate"]=="tdd" and b["status"]=="pass" and "42/42" in b["evidence"]
507
+ print("SI" if ok else "NO")
508
+ PYX
509
+ )" = SI ] && echo "OK el veredicto viaja con gate/status/evidence" || { echo "FAIL cuerpo del veredicto"; fail=1; }
510
+
511
+ # 422: compuerta mecánica del servidor -> rc 6, sin reintento y sin encolar.
512
+ python3 - "$TMP/routes-ga.json" <<'PYX'
513
+ import json,sys
514
+ p=sys.argv[1]; d=json.load(open(p))
515
+ d["POST /slices/sl-7/verdicts"]={"status":422,"body":{"error":"illegal_transition","detail":"dod exige wiring_verified"}}
516
+ json.dump(d,open(p,"w"))
517
+ PYX
518
+ rm -f "$PROJ/.claude/state/outbox/"*.json
519
+ rc=0; err="$(bash "$SO" gate dod pass 2>&1 >/dev/null)" || rc=$?
520
+ [ "$rc" = 6 ] && echo "OK gate 422 -> rc 6" || { echo "FAIL gate 422 rc ($rc)"; fail=1; }
521
+ 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; }
522
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')"
523
+ [ "$n" = 0 ] && echo "OK gate 422 no encola (no se reintenta lo rechazado)" || { echo "FAIL gate 422 encoló ($n)"; fail=1; }
524
+
525
+ # Offline: el veredicto se encola como verdict_reported (protegido) y rc 5.
526
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
527
+ rc=0; bash "$SO" gate journey_smoke pass --evidence "smoke verde" >/dev/null 2>&1 || rc=$?
528
+ [ "$rc" = 5 ] && echo "OK gate offline -> rc 5" || { echo "FAIL gate offline rc ($rc)"; fail=1; }
529
+ grep -lq '"type": "verdict_reported"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
530
+ && echo "OK gate offline encola verdict_reported" || { echo "FAIL gate offline no encoló"; fail=1; }
531
+ rm -f "$PROJ/.claude/state/outbox/"*.json
532
+
533
+ # Regresión: revisión final de rama — _ops_post_or_queue (offline, la rama que gate/submit/
534
+ # checkpoint comparten) NUNCA debe reportar "encolado" si ops_enqueue_from_file falla de
535
+ # verdad. Red YA inalcanzable (runtime_url apunta a :9 desde el bloque anterior).
536
+ chmod 555 "$PROJ/.claude/state/outbox"
537
+ rc=0; out="$(bash "$SO" gate journey_smoke pass --evidence "x" 2>&1)" || rc=$?
538
+ chmod 755 "$PROJ/.claude/state/outbox"
539
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
540
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'encolado (tipo protegido' \
541
+ && echo "OK gate offline con outbox no escribible -> sin encolar, sin aviso de éxito" \
542
+ || { echo "FAIL gate offline con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
543
+ rm -f "$PROJ/.claude/state/outbox/"*.json
544
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
545
+
546
+ # submit
547
+ python3 - "$TMP/routes-ga.json" <<'PYX'
548
+ import json,sys
549
+ p=sys.argv[1]; d=json.load(open(p))
550
+ d["POST /slices/sl-7/verdicts"]={"status":200,"body":{"ok":True}}
551
+ json.dump(d,open(p,"w"))
552
+ PYX
553
+ rc=0; bash "$SO" submit --pr-url "https://example.invalid/pr/1" --openspec-change add-cobros >/dev/null || rc=$?
554
+ [ "$rc" = 0 ] && echo "OK submit -> rc 0" || { echo "FAIL submit rc ($rc)"; fail=1; }
555
+ grep -q '"path": "/slices/sl-7/submit"' "$TMP/calls-ga.jsonl" && echo "OK submit llama a su endpoint" || { echo "FAIL submit endpoint"; fail=1; }
556
+
557
+ # archive: SIEMPRE por la cola (opsx:archive corre dentro del PR) y protegido de la cota.
558
+ rm -f "$PROJ/.claude/state/outbox/"*.json
559
+ rc=0; bash "$SO" archive --epic EP-003 --openspec-change add-cobros --pr-url "https://example.invalid/pr/1" >/dev/null || rc=$?
560
+ [ "$rc" = 0 ] && echo "OK archive -> rc 0" || { echo "FAIL archive rc ($rc)"; fail=1; }
561
+ grep -lq '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
562
+ && echo "OK archive encola slice_archived" || { echo "FAIL archive no encoló"; fail=1; }
563
+ rm -f "$PROJ/.claude/state/outbox/"*.json
564
+
565
+ # Regresión: revisión final de rama — si ops_enqueue_from_file falla, archive NUNCA debe
566
+ # reportar éxito (era el mismo bug ya corregido en _fact_emit, reintroducido aquí).
567
+ chmod 555 "$PROJ/.claude/state/outbox"
568
+ rc=0; out="$(bash "$SO" archive --epic EP-003 --openspec-change add-cobros 2>&1)" || rc=$?
569
+ chmod 755 "$PROJ/.claude/state/outbox"
570
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
571
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'slice_archived encolado' \
572
+ && echo "OK archive con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
573
+ || { echo "FAIL archive con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
574
+ rm -f "$PROJ/.claude/state/outbox/"*.json
575
+
576
+ # dual: el fichero es primario -> se espeja la transición con la identidad LOCAL.
577
+ cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
578
+ {"version":"1.0.0","active_slice":{"epica":"EP-009","openspec_change":"add-pagos","branch":"feature/pagos","phase":"tdd","gates":{"dor":true}}}
579
+ JSON
580
+ : > "$TMP/calls-ga.jsonl"
581
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$SO" gate tdd pass --evidence "verde" >/dev/null || rc=$?
582
+ [ "$rc" = 0 ] && echo "OK gate en dual -> rc 0" || { echo "FAIL gate dual rc ($rc)"; fail=1; }
583
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
584
+ import json,sys
585
+ ok=False
586
+ for line in open(sys.argv[1]):
587
+ c=json.loads(line)
588
+ if c["path"]=="/projects/p-1/mirror/transitions":
589
+ b=json.loads(c["body"])
590
+ ok = b["kind"]=="verdict_reported" and b["origin"]=="mirror" and b["payload"]["slice_ref"]["epic_code"]=="EP-009"
591
+ print("SI" if ok else "NO")
592
+ PYX
593
+ )" = SI ] && echo "OK dual espeja con la identidad fichero-primaria" || { echo "FAIL espejo dual"; fail=1; }
594
+ [ ! -s "$PROJ/.claude/state/outbox" ] || true
595
+ 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)"
596
+
597
+ # dual con espejo caído: avisa pero NO bloquea (rc 0).
598
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
599
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$SO" gate tdd pass >/dev/null 2>&1 || rc=$?
600
+ [ "$rc" = 0 ] && echo "OK dual con espejo caído no bloquea" || { echo "FAIL dual espejo caído rc ($rc)"; fail=1; }
601
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
602
+
603
+ # ══════════════ hooks/build/dual-compare.sh [Task E] ══════════════
604
+ # build-state.json de arriba sigue en pie: EP-009, phase tdd, gates {dor:true}.
605
+ # En dual, escalate NO encola: espeja por POST /projects/{id}/mirror/transitions (el
606
+ # fichero es primario) — igual que el resto de cmd_* en dual (ver el bloque de arriba).
607
+ DC="$ROOT/hooks/build/dual-compare.sh"
608
+ mirror_escalated() {
609
+ # SI/NO: ¿calls-ga.jsonl tiene un POST a mirror/transitions con kind=escalation_raised?
610
+ python3 - "$TMP/calls-ga.jsonl" <<'PYX'
611
+ import json,sys
612
+ found=False
613
+ for line in open(sys.argv[1]):
614
+ try:
615
+ c=json.loads(line)
616
+ except Exception:
617
+ continue
618
+ if c.get("path")=="/projects/p-1/mirror/transitions":
619
+ b=json.loads(c["body"])
620
+ if b.get("kind")=="escalation_raised":
621
+ found=True
622
+ print("SI" if found else "NO")
623
+ PYX
624
+ }
625
+
626
+ # legacy: no toca red ni outbox (fast exit).
627
+ : > "$TMP/calls-ga.jsonl"
628
+ rc=0; TRYCORE_RUNTIME_MODE=legacy bash "$DC" >/dev/null 2>&1 || rc=$?
629
+ [ "$rc" = 0 ] && [ ! -s "$TMP/calls-ga.jsonl" ] \
630
+ && echo "OK dual-compare en legacy no toca red" || { echo "FAIL dual-compare legacy (rc=$rc)"; fail=1; }
631
+
632
+ # runtime (no dual): tampoco corre — el comparador es específico de dual.
633
+ : > "$TMP/calls-ga.jsonl"
634
+ rc=0; TRYCORE_RUNTIME_MODE=runtime bash "$DC" >/dev/null 2>&1 || rc=$?
635
+ [ "$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; }
636
+
637
+ # dual, proyección COINCIDENTE (épica/fase/gates iguales): sin discrepancia, sin escalar.
638
+ python3 - "$TMP/routes-ga.json" <<'PYX'
639
+ import json,sys
640
+ p=sys.argv[1]; d=json.load(open(p))
641
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-009","phase":"tdd","gates":{"dor":True}}}}
642
+ json.dump(d,open(p,"w"))
643
+ PYX
644
+ : > "$TMP/calls-ga.jsonl"
645
+ rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
646
+ [ "$rc" = 0 ] && [ -z "$err" ] && [ "$(mirror_escalated)" = NO ] \
647
+ && echo "OK dual-compare sin discrepancia -> silencioso, sin escalar" || { echo "FAIL dual-compare sin discrepancia (rc=$rc err=$err)"; fail=1; }
648
+
649
+ # dual, épica DISTINTA en el runtime: discrepancia -> escala vía slice-ops.sh escalate (espejo).
650
+ python3 - "$TMP/routes-ga.json" <<'PYX'
651
+ import json,sys
652
+ p=sys.argv[1]; d=json.load(open(p))
653
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-999","phase":"tdd","gates":{"dor":True}}}}
654
+ json.dump(d,open(p,"w"))
655
+ PYX
656
+ : > "$TMP/calls-ga.jsonl"
657
+ rc=0; err="$(TRYCORE_RUNTIME_MODE=dual bash "$DC" 2>&1 1>/dev/null)" || rc=$?
658
+ [ "$rc" = 0 ] && [ "$(mirror_escalated)" = SI ] && echo "$err" | grep -qi "discrepancia" \
659
+ && echo "$err" | grep -q "EP-009" && echo "$err" | grep -q "EP-999" \
660
+ && 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; }
661
+
662
+ # dual, gate DISTINTO (mismo epic/fase): también cuenta como discrepancia.
663
+ python3 - "$TMP/routes-ga.json" <<'PYX'
664
+ import json,sys
665
+ p=sys.argv[1]; d=json.load(open(p))
666
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-009","phase":"tdd","gates":{"dor":False}}}}
667
+ json.dump(d,open(p,"w"))
668
+ PYX
669
+ : > "$TMP/calls-ga.jsonl"
670
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
671
+ [ "$(mirror_escalated)" = SI ] && echo "OK dual-compare detecta gate distinto (dor: true vs false)" || { echo "FAIL dual-compare gate distinto"; fail=1; }
672
+
673
+ # dual, runtime SIN slice activo (el fichero sí tiene): también es discrepancia.
674
+ python3 - "$TMP/routes-ga.json" <<'PYX'
675
+ import json,sys
676
+ p=sys.argv[1]; d=json.load(open(p))
677
+ d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True}}}
678
+ json.dump(d,open(p,"w"))
679
+ PYX
680
+ : > "$TMP/calls-ga.jsonl"
681
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
682
+ [ "$(mirror_escalated)" = SI ] && echo "OK dual-compare detecta slice ausente en el runtime" || { echo "FAIL dual-compare slice ausente en runtime"; fail=1; }
683
+
684
+ # fail-open: espejo caído (runtime inalcanzable) -> nunca lanza, nunca aborta (rc 0 siempre).
685
+ python3 - "$TMP/routes-ga.json" <<'PYX'
686
+ import json,sys
687
+ p=sys.argv[1]; d=json.load(open(p))
688
+ d["GET /agent/context"]={"status":200,"body":{"active_slice":{"slice_id":"sl-9","epic_code":"EP-999","phase":"tdd","gates":{"dor":True}}}}
689
+ json.dump(d,open(p,"w"))
690
+ PYX
691
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
692
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
693
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
694
+ [ "$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; }
695
+
696
+ # sin build-state.json: nada que comparar, silencioso.
697
+ rm -f "$PROJ/.claude/state/build-state.json"
698
+ rc=0; TRYCORE_RUNTIME_MODE=dual bash "$DC" >/dev/null 2>&1 || rc=$?
699
+ [ "$rc" = 0 ] && echo "OK dual-compare sin build-state.json -> no-op" || { echo "FAIL dual-compare sin build-state.json (rc=$rc)"; fail=1; }
700
+
701
+ # Restaura /agent/context y la caché de proyección al estado que esperan los bloques
702
+ # siguientes (slice_id=sl-7): el comparador de arriba mutó routes-ga.json varias veces.
703
+ python3 - "$TMP/routes-ga.json" <<'PYX'
704
+ import json,sys
705
+ p=sys.argv[1]; d=json.load(open(p))
706
+ 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"}}}
707
+ json.dump(d,open(p,"w"))
708
+ PYX
709
+ agent_context_fetch_and_cache
710
+
711
+ # ══════════════ slice-ops.sh: wiring / progress / checkpoint [Task 4] ══════════════
712
+ python3 - "$TMP/routes-ga.json" <<'PYX'
713
+ import json,sys
714
+ p=sys.argv[1]; d=json.load(open(p))
715
+ d["POST /checkpoints"]={"status":200,"body":{"ok":True}}
716
+ json.dump(d,open(p,"w"))
717
+ PYX
718
+ rm -f "$PROJ/.claude/state/outbox/"*.json
719
+
720
+ # wiring seed: un evento con TODOS los items, payload por fichero (sin ARG_MAX).
721
+ python3 -c "
722
+ import json
723
+ 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'))"
724
+ rc=0; bash "$SO" wiring seed --file "$TMP/items.json" >/dev/null || rc=$?
725
+ [ "$rc" = 0 ] && echo "OK wiring seed -> rc 0" || { echo "FAIL wiring seed rc ($rc)"; fail=1; }
726
+ f="$(grep -l '"type": "wiring_checklist_seeded"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
727
+ [ -n "$f" ] && [ "$(python3 -c "import json;print(len(json.load(open('$f'))['payload']['items']))")" = 300 ] \
728
+ && echo "OK wiring seed encola los 300 items en un evento" || { echo "FAIL wiring seed payload"; fail=1; }
729
+ [ "$(python3 -c "import json;print(json.load(open('$f')).get('slice_id'))")" = "sl-7" ] \
730
+ && echo "OK wiring seed lleva slice_id" || { echo "FAIL wiring seed sin slice_id"; fail=1; }
731
+ rm -f "$PROJ/.claude/state/outbox/"*.json
732
+
733
+ # Regresión: revisión final de rama — outbox no escribible -> wiring seed no reporta éxito.
734
+ python3 -c "import json;json.dump([{'item_id':'w0','kind':'ac','ref':'HU-01'}], open('$TMP/items-small.json','w'))"
735
+ chmod 555 "$PROJ/.claude/state/outbox"
736
+ rc=0; out="$(bash "$SO" wiring seed --file "$TMP/items-small.json" 2>&1)" || rc=$?
737
+ chmod 755 "$PROJ/.claude/state/outbox"
738
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
739
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'checklist de wiring sembrado' \
740
+ && echo "OK wiring seed con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
741
+ || { echo "FAIL wiring seed con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
742
+ rm -f "$PROJ/.claude/state/outbox/"*.json
743
+
744
+ # wiring update: status + kind/ref/evidence (spec §4.1-C amplía el agregado slice).
745
+ rc=0; bash "$SO" wiring update w1 passing --kind e2e --ref HU-011 --evidence "newman 12/12" >/dev/null || rc=$?
746
+ [ "$rc" = 0 ] && echo "OK wiring update -> rc 0" || { echo "FAIL wiring update rc ($rc)"; fail=1; }
747
+ f="$(grep -l '"type": "wiring_item_updated"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | head -1)"
748
+ [ "$(python3 -c "
749
+ import json;d=json.load(open('$f'))['payload']
750
+ 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 ] \
751
+ && echo "OK wiring update viaja completo" || { echo "FAIL wiring update payload"; fail=1; }
752
+ # Estado inválido: rechazo local, nada encolado.
753
+ rm -f "$PROJ/.claude/state/outbox/"*.json
754
+ rc=0; bash "$SO" wiring update w1 verde >/dev/null 2>&1 || rc=$?
755
+ [ "$rc" = 6 ] && echo "OK wiring update estado inválido -> rc 6" || { echo "FAIL wiring update inválido rc ($rc)"; fail=1; }
756
+ [ "$(find "$PROJ/.claude/state/outbox" -name '*.json' | wc -l | tr -d ' ')" = 0 ] \
757
+ && echo "OK wiring update inválido no encola" || { echo "FAIL wiring update inválido encoló"; fail=1; }
758
+
759
+ # Regresión: revisión final de rama — outbox no escribible -> wiring update no reporta éxito.
760
+ chmod 555 "$PROJ/.claude/state/outbox"
761
+ rc=0; out="$(bash "$SO" wiring update w1 passing --evidence "x" 2>&1)" || rc=$?
762
+ chmod 755 "$PROJ/.claude/state/outbox"
763
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
764
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'evento encolado' \
765
+ && echo "OK wiring update con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
766
+ || { echo "FAIL wiring update con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
767
+ rm -f "$PROJ/.claude/state/outbox/"*.json
768
+
769
+ # progress: nota por argumento y por fichero; tipo EVICTABLE (no protegido).
770
+ rc=0; bash "$SO" progress "cableado el paso 2 del journey" >/dev/null || rc=$?
771
+ [ "$rc" = 0 ] && echo "OK progress -> rc 0" || { echo "FAIL progress rc ($rc)"; fail=1; }
772
+ case " $RUNTIME_OUTBOX_PROTECTED " in
773
+ *" progress_note_recorded "*) echo "FAIL progress_note_recorded no debe estar protegido"; fail=1 ;;
774
+ *) echo "OK progress_note_recorded es evictable" ;;
775
+ esac
776
+ rm -f "$PROJ/.claude/state/outbox/"*.json
777
+
778
+ # Regresión: revisión final de rama — outbox no escribible -> progress no reporta éxito.
779
+ chmod 555 "$PROJ/.claude/state/outbox"
780
+ rc=0; out="$(bash "$SO" progress "nota cualquiera" 2>&1)" || rc=$?
781
+ chmod 755 "$PROJ/.claude/state/outbox"
782
+ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
783
+ [ "$rc" = "$OPS_RC_OFFLINE" ] && [ "$n" = 0 ] && ! echo "$out" | grep -q 'nota de progreso encolada' \
784
+ && echo "OK progress con outbox no escribible -> rc distinguible, sin encolar, sin aviso de éxito" \
785
+ || { echo "FAIL progress con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
786
+ rm -f "$PROJ/.claude/state/outbox/"*.json
787
+
788
+ # checkpoint: POST con rama y commit reales del repo del PROYECTO.
789
+ : > "$TMP/calls-ga.jsonl"
790
+ rc=0; bash "$SO" checkpoint --note "parada por contexto crítico" >/dev/null || rc=$?
791
+ [ "$rc" = 0 ] && echo "OK checkpoint -> rc 0" || { echo "FAIL checkpoint rc ($rc)"; fail=1; }
792
+ [ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
793
+ import json,sys
794
+ ok=False
795
+ for line in open(sys.argv[1]):
796
+ c=json.loads(line)
797
+ if c["path"]=="/checkpoints":
798
+ b=json.loads(c["body"])
799
+ ok = b.get("slice_id")=="sl-7" and "note" in b and "branch" in b and "commit_sha" in b
800
+ print("SI" if ok else "NO")
801
+ PYX
802
+ )" = SI ] && echo "OK el checkpoint viaja con slice/branch/commit/note" || { echo "FAIL cuerpo del checkpoint"; fail=1; }
803
+
804
+ # checkpoint offline: se encola como checkpoint_created (protegido).
805
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
806
+ rm -f "$PROJ/.claude/state/outbox/"*.json
807
+ rc=0; bash "$SO" checkpoint --note "sin red" >/dev/null 2>&1 || rc=$?
808
+ [ "$rc" = 5 ] && echo "OK checkpoint offline -> rc 5" || { echo "FAIL checkpoint offline rc ($rc)"; fail=1; }
809
+ grep -lq '"type": "checkpoint_created"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null \
810
+ && echo "OK checkpoint offline encola" || { echo "FAIL checkpoint offline no encoló"; fail=1; }
811
+ rm -f "$PROJ/.claude/state/outbox/"*.json
812
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
813
+
814
+ # ══════════════ scripts/lib/graph-bundle.py [Task 13] ══════════════
815
+ GB="$ROOT/scripts/lib/graph-bundle.py"
816
+ cat > "$TMP/epics.json" <<'JSON'
817
+ {"project_ref":"acme","epics":[
818
+ {"code":"EP-002","title":"Cobros","layer":"business","files_scope":["src/pagos/**"],"depends_on":["EP-001"],"stories":[{"id":"HU-011","title":"Cobrar"}]},
819
+ {"code":"EP-001","title":"Cimiento","layer":"foundational","files_scope":["src/core/**"],"depends_on":[]},
820
+ {"code":"EP-002","title":"Duplicada","layer":"business"},
821
+ {"code":"EP-003","title":"Suelta","depends_on":["EP-404"]}
822
+ ],"release_lines":[{"id":"R1-mvp","epics":["EP-001","EP-002"]}]}
823
+ JSON
824
+ out="$(python3 "$GB" < "$TMP/epics.json" 2> "$TMP/gb.err")"
825
+ rc=$?
826
+ [ "$rc" = 0 ] && echo "OK graph-bundle rc 0" || { echo "FAIL graph-bundle rc ($rc)"; fail=1; }
827
+ [ "$(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" ] \
828
+ && echo "OK graph-bundle deduplica y ordena determinísticamente" || { echo "FAIL orden/dedup del bundle"; fail=1; }
829
+ grep -q "EP-002" "$TMP/gb.err" && echo "OK graph-bundle avisa del código duplicado" || { echo "FAIL sin aviso de duplicado"; fail=1; }
830
+ grep -q "EP-404" "$TMP/gb.err" && echo "OK graph-bundle avisa de la dependencia inexistente" || { echo "FAIL sin aviso de dep inexistente"; fail=1; }
831
+ [ "$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["kind"],d["bundle_version"])')" = "graph 1" ] \
832
+ && echo "OK el bundle se autodescribe (kind/bundle_version)" || { echo "FAIL cabecera del bundle"; fail=1; }
833
+ # El bundle es DETERMINISTA: dos corridas idénticas salvo el sello temporal.
834
+ out2="$(python3 "$GB" < "$TMP/epics.json" 2>/dev/null)"
835
+ a="$(echo "$out" | python3 -c 'import json,sys;d=json.load(sys.stdin);d.pop("generated_at");print(json.dumps(d,sort_keys=True))')"
836
+ b="$(echo "$out2" | python3 -c 'import json,sys;d=json.load(sys.stdin);d.pop("generated_at");print(json.dumps(d,sort_keys=True))')"
837
+ [ "$a" = "$b" ] && echo "OK el bundle es determinista" || { echo "FAIL el bundle no es determinista"; fail=1; }
838
+ # Ciclo: se avisa, no se lanza.
839
+ echo '{"epics":[{"code":"EP-A","depends_on":["EP-B"]},{"code":"EP-B","depends_on":["EP-A"]}]}' | python3 "$GB" > /dev/null 2> "$TMP/gb2.err"
840
+ rc=$?
841
+ [ "$rc" = 0 ] && grep -qi "ciclo" "$TMP/gb2.err" && echo "OK graph-bundle detecta ciclos sin lanzar" || { echo "FAIL ciclos ($rc)"; fail=1; }
842
+ # Entrada ilegible: rc 1 y mensaje, jamás traceback.
843
+ echo 'no-json' | python3 "$GB" > /dev/null 2> "$TMP/gb3.err"
844
+ rc=$?
845
+ [ "$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; }
846
+
847
+ exit $fail