@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.
- package/.claude-plugin/plugin.json +1 -1
- package/GOVERNANCE.md +27 -4
- package/INSTALL.md +27 -5
- package/METODOLOGIA.md +55 -5
- package/README.md +39 -6
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +33 -7
- package/agents/build/dor-dod-gatekeeper.md +13 -5
- package/agents/build/wiring-adversarial-verifier.md +52 -5
- package/commands/build/architect.md +1 -1
- package/commands/build/claim.md +46 -0
- package/commands/build/escalate.md +36 -0
- package/commands/build/front.md +9 -3
- package/commands/build/onboard.md +75 -14
- package/commands/build/prototype.md +3 -2
- package/commands/build/reflect.md +60 -40
- package/commands/build/release.md +10 -7
- package/commands/build/resume.md +33 -13
- package/commands/build/slice.md +32 -27
- package/commands/build/status.md +35 -0
- package/commands/build/work.md +11 -8
- package/config/build-config.template.json +4 -0
- package/dist/cli.js +32 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/init.js +84 -1
- package/dist/commands/migrate.js +153 -0
- package/dist/commands/status.js +34 -0
- package/dist/lib/normalize.js +1123 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/runtime-client.js +196 -0
- package/dist/lib/settings-merge.js +3 -3
- package/dist/lib/state-bundle.js +150 -0
- package/docs/commands.md +25 -8
- package/docs/getting-started.md +1 -0
- package/docs/hooks.md +114 -27
- package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
- package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
- package/docs/runtime/protocolo-cliente-runtime.md +120 -35
- package/hooks/build/build-gate-check.sh +21 -0
- package/hooks/build/context-monitor.sh +82 -15
- package/hooks/build/context-sync.sh +192 -0
- package/hooks/build/design-source-guard.sh +30 -2
- package/hooks/build/dual-compare.sh +92 -0
- package/hooks/build/event-emitter.sh +32 -0
- package/hooks/build/gitflow-guard.sh +164 -14
- package/hooks/build/heartbeat.sh +259 -0
- package/hooks/build/lib/agent-context.sh +139 -0
- package/hooks/build/lib/config.sh +27 -0
- package/hooks/build/lib/projection.sh +71 -0
- package/hooks/build/lib/runtime-client.sh +625 -0
- package/hooks/build/lib/runtime-ops.sh +227 -0
- package/hooks/build/lib/state-io.sh +5 -18
- package/hooks/build/load-build-state.sh +64 -2
- package/hooks/build/reflect-nudge.sh +15 -0
- package/hooks/build/release-gate-nudge.sh +15 -0
- package/hooks/build/release-ops.sh +171 -0
- package/hooks/build/scaffold-guard.sh +29 -2
- package/hooks/build/session-start.sh +103 -0
- package/hooks/build/session-stop.sh +22 -0
- package/hooks/build/slice-ops.sh +948 -0
- package/hooks/build/stack-guard.sh +8 -0
- package/hooks/build/statusline-bridge.sh +24 -3
- package/hooks/build-harness.json +16 -0
- package/package.json +3 -3
- package/scripts/check-agnostic.sh +3 -1
- package/scripts/check-pack-clean.sh +31 -0
- package/scripts/check-runtime-purity.sh +43 -0
- package/scripts/denylist.txt +4 -0
- package/scripts/lib/front-plan.py +4 -0
- package/scripts/lib/graph-bundle.py +181 -0
- package/scripts/runtime-purity-allow.txt +5 -0
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/lib/http-stub.py +46 -0
- package/scripts/tests/test-baseline-verdict.sh +92 -0
- package/scripts/tests/test-config.sh +25 -0
- package/scripts/tests/test-hooks-runtime.sh +828 -0
- package/scripts/tests/test-install.sh +103 -0
- package/scripts/tests/test-runtime-client.sh +298 -0
- package/scripts/tests/test-schema.sh +29 -1
- package/scripts/tests/test-skill-ops.sh +1367 -0
- package/skills/building-a-micro-change/SKILL.md +22 -4
- package/skills/building-a-slice/SKILL.md +55 -21
- package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
- package/skills/building-a-slice/references/dod.md +12 -3
- package/skills/building-a-slice/references/dor.md +3 -2
- package/skills/building-a-slice/references/evidence-budget.md +51 -0
- package/skills/building-a-slice/references/exploration-fanout.md +1 -1
- package/skills/building-a-slice/references/gitflow.md +1 -1
- package/skills/building-a-slice/references/regression-baseline.md +67 -0
- package/skills/building-a-slice/references/runtime-protocol.md +75 -0
- package/skills/building-a-slice/references/state-protocol.md +12 -1
- package/skills/building-a-slice/workflows/README.md +7 -3
- package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
- package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
- package/skills/managing-parallel-front/SKILL.md +32 -16
- package/skills/openspec-archive-change/SKILL.md +15 -0
- package/skills/prototyping-screens/SKILL.md +9 -5
- package/skills/releasing-a-version/SKILL.md +26 -16
- package/skills/releasing-a-version/references/release-dod.md +7 -5
- package/skills/releasing-a-version/workflows/README.md +2 -1
- package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
- package/skills/setup-architecture/SKILL.md +4 -2
- package/state/README.md +16 -1
- package/state/build-state.schema.json +2 -1
- package/templates/CLAUDE.md.template +16 -0
- package/templates/settings-hooks.template.json +8 -4
- 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
|