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