@trycore/spec-build-harness 0.14.2 → 0.15.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/README.md +1 -0
- package/VERSION +1 -1
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +2 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +37 -2
- package/docs/runtime/protocolo-cliente-runtime.md +56 -7
- package/hooks/build/lib/runtime-client.sh +55 -12
- package/hooks/build/lib/runtime-ops.sh +135 -27
- package/hooks/build/release-ops.sh +13 -8
- package/hooks/build/slice-ops.sh +409 -41
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/tests/test-install.sh +2 -1
- package/scripts/tests/test-runtime-client.sh +49 -3
- package/scripts/tests/test-skill-ops.sh +516 -27
|
@@ -21,10 +21,17 @@ grafo de forma DETERMINISTA — regex de códigos `EP-…` sobre el campo `Depen
|
|
|
21
21
|
sección de épica, excluyendo la propia — y lo une a lo que traiga el JSON de entrada. Si la
|
|
22
22
|
épica declara el campo estructurado `depende_de: [EP-001, …]`, ese gana sobre la prosa.
|
|
23
23
|
|
|
24
|
+
[EP-OR-17] `--for-sync` emite la PROYECCIÓN DE CONTRATO del carril `graph-sync`: `{"epics":[…]}`
|
|
25
|
+
y nada más (`GraphSyncBundle` es `extra="forbid"`; las claves de preparación son un 422). En ese
|
|
26
|
+
modo lo que aquí es aviso pasa a ser RECHAZO con rc 2 — el import de admin lo revisa un humano
|
|
27
|
+
antes de subirlo, pero una propuesta de re-sync viaja sola, y el hub rechaza el grafo ENTERO ante
|
|
28
|
+
una sola épica mal formada.
|
|
29
|
+
|
|
24
30
|
Uso:
|
|
25
31
|
python3 graph-bundle.py < epics.json > bundle.json # avisos por stderr
|
|
26
32
|
python3 graph-bundle.py --from-docs docs/03-backlog/epicas.md < epics.json > bundle.json
|
|
27
33
|
python3 graph-bundle.py --print-deps docs/03-backlog/epicas.md # solo el grafo extraído
|
|
34
|
+
python3 graph-bundle.py --for-sync --from-docs docs/03-backlog/epicas.md < epics.json
|
|
28
35
|
|
|
29
36
|
Entrada (stdin, JSON — formato de discovery, sin cambios):
|
|
30
37
|
{"project_ref": "…",
|
|
@@ -38,7 +45,9 @@ el criterio del arnés — nada se pierde en silencio, nada bloquea la preparaci
|
|
|
38
45
|
|
|
39
46
|
Códigos de salida: 0 = bundle generado (con o sin avisos) · 1 = entrada ilegible
|
|
40
47
|
(incluye una ruta `--from-docs` que no se puede leer: se pidió extraer y no se pudo, y un
|
|
41
|
-
grafo sin aristas no falla solo — empobrece en silencio, que es justo lo que causó #56)
|
|
48
|
+
grafo sin aristas no falla solo — empobrece en silencio, que es justo lo que causó #56)
|
|
49
|
+
· 2 = solo con `--for-sync`: el bundle no cumple el contrato del hub (un motivo por línea en
|
|
50
|
+
stderr, cada uno nombrando la épica y el campo).
|
|
42
51
|
"""
|
|
43
52
|
import json
|
|
44
53
|
import re
|
|
@@ -127,11 +136,15 @@ def leer_dependencias_de_docs(ruta):
|
|
|
127
136
|
|
|
128
137
|
|
|
129
138
|
def parsear_argumentos(argv):
|
|
130
|
-
"""({from_docs, print_deps}, error) — sin dependencias, mismo estilo que el resto."""
|
|
131
|
-
opciones = {"from_docs": None, "print_deps": None}
|
|
139
|
+
"""({from_docs, print_deps, for_sync}, error) — sin dependencias, mismo estilo que el resto."""
|
|
140
|
+
opciones = {"from_docs": None, "print_deps": None, "for_sync": False}
|
|
132
141
|
i = 0
|
|
133
142
|
while i < len(argv):
|
|
134
143
|
arg = argv[i]
|
|
144
|
+
if arg == "--for-sync":
|
|
145
|
+
opciones["for_sync"] = True
|
|
146
|
+
i += 1
|
|
147
|
+
continue
|
|
135
148
|
if arg in ("--from-docs", "--print-deps"):
|
|
136
149
|
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
|
137
150
|
return opciones, "%s exige la ruta del fichero de épicas" % arg
|
|
@@ -145,6 +158,100 @@ def parsear_argumentos(argv):
|
|
|
145
158
|
return opciones, None
|
|
146
159
|
|
|
147
160
|
|
|
161
|
+
# ── Proyección de contrato para el carril `graph-sync` [EP-OR-17] ─────────────────────
|
|
162
|
+
# `GraphSyncBundle` (event_catalog.py del hub) es `extra="forbid"` y admite SOLO `epics`: las
|
|
163
|
+
# claves de preparación (`bundle_version`, `kind`, `project_ref`, `generated_at`,
|
|
164
|
+
# `generated_by`, `depends_on_source`, `warnings`) son un 422 garantizado.
|
|
165
|
+
#
|
|
166
|
+
# Y aquí los avisos se vuelven RECHAZOS. El bundle de preparación puede seguir adelante con
|
|
167
|
+
# `layer` ausente porque un ADMIN lo revisa antes de subirlo; el carril de sync no tiene humano
|
|
168
|
+
# en medio, y el hub rechaza el grafo ENTERO ante una sola épica mal formada. Un aviso que nadie
|
|
169
|
+
# lee es un 422 con pasos extra.
|
|
170
|
+
MAX_EPICAS = 200
|
|
171
|
+
MAX_HISTORIAS = 100
|
|
172
|
+
MAX_FILES_SCOPE = 200
|
|
173
|
+
MAX_DEPENDS_ON = 50
|
|
174
|
+
LARGOS = {"code": 50, "title": 300, "release_line": 100, "docs_ref": 500,
|
|
175
|
+
"files_scope_item": 500, "depends_on_item": 50}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def emitir_para_sync(epicas):
|
|
179
|
+
"""Imprime `{"epics":[…]}` en el formato del contrato; rc 2 si algo no lo cumple."""
|
|
180
|
+
errores = []
|
|
181
|
+
if not epicas:
|
|
182
|
+
errores.append("el bundle no tiene ni una épica: no hay nada que proponer")
|
|
183
|
+
if len(epicas) > MAX_EPICAS:
|
|
184
|
+
errores.append("%d épicas: el contrato admite %d como máximo"
|
|
185
|
+
% (len(epicas), MAX_EPICAS))
|
|
186
|
+
salida = []
|
|
187
|
+
for code in sorted(epicas):
|
|
188
|
+
e = epicas[code]
|
|
189
|
+
if len(code) > LARGOS["code"]:
|
|
190
|
+
errores.append("%s: el `code` excede %d caracteres" % (code, LARGOS["code"]))
|
|
191
|
+
if not e.get("title"):
|
|
192
|
+
errores.append("%s: sin `title` (el contrato lo exige no vacío)" % code)
|
|
193
|
+
elif len(e["title"]) > LARGOS["title"]:
|
|
194
|
+
errores.append("%s: el `title` excede %d caracteres" % (code, LARGOS["title"]))
|
|
195
|
+
if e.get("layer") not in VALID_LAYERS:
|
|
196
|
+
errores.append("%s: `layer` es %r y el contrato exige FOUNDATIONAL o BUSINESS "
|
|
197
|
+
"(declárala en los docs de discovery)" % (code, e.get("layer")))
|
|
198
|
+
fs = e.get("files_scope") or []
|
|
199
|
+
if len(fs) > MAX_FILES_SCOPE:
|
|
200
|
+
errores.append("%s: %d entradas en `files_scope` y el contrato admite %d"
|
|
201
|
+
% (code, len(fs), MAX_FILES_SCOPE))
|
|
202
|
+
for x in fs:
|
|
203
|
+
if not x or len(x) > LARGOS["files_scope_item"]:
|
|
204
|
+
errores.append("%s: una entrada de `files_scope` está vacía o excede %d "
|
|
205
|
+
"caracteres" % (code, LARGOS["files_scope_item"]))
|
|
206
|
+
break
|
|
207
|
+
deps = e.get("depends_on") or []
|
|
208
|
+
if len(deps) > MAX_DEPENDS_ON:
|
|
209
|
+
errores.append("%s: %d entradas en `depends_on` y el contrato admite %d"
|
|
210
|
+
% (code, len(deps), MAX_DEPENDS_ON))
|
|
211
|
+
for x in deps:
|
|
212
|
+
if not x or len(x) > LARGOS["depends_on_item"]:
|
|
213
|
+
errores.append("%s: una entrada de `depends_on` está vacía o excede %d "
|
|
214
|
+
"caracteres" % (code, LARGOS["depends_on_item"]))
|
|
215
|
+
break
|
|
216
|
+
historias = e.get("stories") or []
|
|
217
|
+
if len(historias) > MAX_HISTORIAS:
|
|
218
|
+
errores.append("%s: %d historias y el contrato admite %d por épica"
|
|
219
|
+
% (code, len(historias), MAX_HISTORIAS))
|
|
220
|
+
hs = []
|
|
221
|
+
for h in historias:
|
|
222
|
+
hcode = h.get("code") or ""
|
|
223
|
+
if not hcode or len(hcode) > LARGOS["code"]:
|
|
224
|
+
errores.append("%s: una historia no tiene `code` o excede %d caracteres"
|
|
225
|
+
% (code, LARGOS["code"]))
|
|
226
|
+
continue
|
|
227
|
+
htitle = h.get("title") or ""
|
|
228
|
+
if not htitle:
|
|
229
|
+
errores.append("%s: la historia %s no tiene `title`" % (code, hcode))
|
|
230
|
+
continue
|
|
231
|
+
hs.append({"code": hcode, "title": htitle[:LARGOS["title"]]})
|
|
232
|
+
proyectada = {"code": code, "title": (e.get("title") or "")[:LARGOS["title"]],
|
|
233
|
+
"layer": e.get("layer"),
|
|
234
|
+
"files_scope": fs, "depends_on": deps, "stories": hs}
|
|
235
|
+
# Opcionales: solo viajan si tienen valor. `null` es legal en el contrato, pero omitirlos
|
|
236
|
+
# deja más corto el delta que el humano revisa en la consola.
|
|
237
|
+
if e.get("release_line"):
|
|
238
|
+
proyectada["release_line"] = e["release_line"][:LARGOS["release_line"]]
|
|
239
|
+
if isinstance(e.get("priority"), int):
|
|
240
|
+
proyectada["priority"] = e["priority"]
|
|
241
|
+
if e.get("docs_ref"):
|
|
242
|
+
proyectada["docs_ref"] = e["docs_ref"][:LARGOS["docs_ref"]]
|
|
243
|
+
salida.append(proyectada)
|
|
244
|
+
if errores:
|
|
245
|
+
print("graph-bundle --for-sync: el bundle NO cumple el contrato del hub "
|
|
246
|
+
"(no se envía nada):", file=sys.stderr)
|
|
247
|
+
for x in errores:
|
|
248
|
+
print(" ⛔ %s" % x, file=sys.stderr)
|
|
249
|
+
return 2
|
|
250
|
+
json.dump({"epics": salida}, sys.stdout, indent=2, ensure_ascii=False)
|
|
251
|
+
sys.stdout.write("\n")
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
148
255
|
def main():
|
|
149
256
|
avisos = []
|
|
150
257
|
opciones, error = parsear_argumentos(sys.argv[1:])
|
|
@@ -322,6 +429,14 @@ def main():
|
|
|
322
429
|
"(el hub guarda UNA línea por épica)"
|
|
323
430
|
% (c, epicas[c]["release_line"], r["id"]))
|
|
324
431
|
|
|
432
|
+
# [EP-OR-17] El carril de sync se lleva la proyección de contrato y NADA más: los avisos
|
|
433
|
+
# siguen saliendo por stderr (arriba se han ido acumulando), pero el `warnings` del bundle
|
|
434
|
+
# de preparación no existe en `GraphSyncBundle` y viajaría como un 422.
|
|
435
|
+
if opciones["for_sync"]:
|
|
436
|
+
for a in avisos:
|
|
437
|
+
print("aviso: %s" % a, file=sys.stderr)
|
|
438
|
+
return emitir_para_sync(epicas)
|
|
439
|
+
|
|
325
440
|
bundle = {
|
|
326
441
|
"bundle_version": BUNDLE_VERSION,
|
|
327
442
|
"kind": "graph",
|
|
@@ -177,8 +177,9 @@ fi
|
|
|
177
177
|
# grafo son estado derivado del hub, igual que los de arriba: mismo blindaje.
|
|
178
178
|
if [ -f "$GI" ] \
|
|
179
179
|
&& grep -q '^\.claude/state/epic-proposals\.json$' "$GI" \
|
|
180
|
+
&& grep -q '^\.claude/state/graph-sync-proposals\.json$' "$GI" \
|
|
180
181
|
&& grep -q '^\.claude/state/graph-status\.json$' "$GI"; then
|
|
181
|
-
echo "OK install: .gitignore protege epic-proposals/graph-status"
|
|
182
|
+
echo "OK install: .gitignore protege epic-proposals/graph-sync-proposals/graph-status"
|
|
182
183
|
else
|
|
183
184
|
echo "FAIL install: .gitignore no protege epic-proposals.json/graph-status.json"
|
|
184
185
|
fail=1
|
|
@@ -320,6 +320,23 @@ echo '{"title":"Notificaciones","objective":"avisar al usuario"}' > "$TMP/prop.j
|
|
|
320
320
|
CID="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
321
321
|
[ -n "$CID" ] && echo "OK runtime_enqueue_direct devuelve el client_event_id" \
|
|
322
322
|
|| { echo "FAIL runtime_enqueue_direct sin id"; fail=1; }
|
|
323
|
+
|
|
324
|
+
# [EP-OR-17] El cid FIJABLE es lo que hace idempotente el reintento del re-sync: el hub
|
|
325
|
+
# deduplica por client_event_id, así que re-encolar tras un 409 no crea una segunda propuesta.
|
|
326
|
+
cid_fijo="$(runtime_enqueue_direct graph_sync_proposed POST /p/1 "$TMP/prop.json" 1 "ak-1:graph-sync:3")"
|
|
327
|
+
[ "$cid_fijo" = "ak-1:graph-sync:3" ] \
|
|
328
|
+
&& echo "OK enqueue_direct devuelve el cid fijado" \
|
|
329
|
+
|| { echo "FAIL cid fijado ('$cid_fijo')"; fail=1; }
|
|
330
|
+
n_fijo="$(grep -l '"client_event_id": "ak-1:graph-sync:3"' "$CLAUDE_PROJECT_DIR/.claude/state/outbox"/*.json 2>/dev/null | wc -l | tr -d ' ')"
|
|
331
|
+
[ "$n_fijo" = 1 ] && echo "OK el cid fijado se persiste en el fichero encolado" \
|
|
332
|
+
|| { echo "FAIL cid no persistido ($n_fijo)"; fail=1; }
|
|
333
|
+
cid_uuid="$(runtime_enqueue_direct graph_sync_proposed POST /p/1 "$TMP/prop.json" 1)"
|
|
334
|
+
[ -n "$cid_uuid" ] && [ "$cid_uuid" != "ak-1:graph-sync:3" ] \
|
|
335
|
+
&& echo "OK sin 6º argumento sigue generando uuid (carril de épicas intacto)" \
|
|
336
|
+
|| { echo "FAIL regresión del uuid ('$cid_uuid')"; fail=1; }
|
|
337
|
+
# Los dos encolados de prueba se retiran: los bloques de abajo cuentan ficheros de la cola.
|
|
338
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox"/*"$cid_fijo".json \
|
|
339
|
+
"$CLAUDE_PROJECT_DIR/.claude/state/outbox"/*"$cid_uuid".json
|
|
323
340
|
f="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | head -1)"
|
|
324
341
|
python3 -c "
|
|
325
342
|
import json
|
|
@@ -490,10 +507,37 @@ PY
|
|
|
490
507
|
)"
|
|
491
508
|
[ "$has" = NO ] && echo "OK sin estampado pedido, el cuerpo no cambia" || { echo "FAIL estampado no pedido"; fail=1; }
|
|
492
509
|
|
|
493
|
-
#
|
|
494
|
-
#
|
|
510
|
+
# [EP-OR-17] El hub NUNCA responde `reason: stale_graph`: emite
|
|
511
|
+
# `{"detail":{"error":"graph_version_stale","graph_version":N}}` (un HTTPException de FastAPI
|
|
512
|
+
# anida SIEMPRE bajo `detail`). El helper acepta las dos formas — la real y la plana legacy —
|
|
513
|
+
# y ninguna otra.
|
|
514
|
+
gv="$(runtime_stale_graph_version '{"detail":{"error":"graph_version_stale","message":"x","graph_version":88}}')" && rcx=0 || rcx=$?
|
|
515
|
+
[ "$rcx" = 0 ] && [ "$gv" = 88 ] \
|
|
516
|
+
&& echo "OK stale: detail.error anidado (forma real del hub)" \
|
|
517
|
+
|| { echo "FAIL detail.error anidado (rc $rcx, gv '$gv')"; fail=1; }
|
|
518
|
+
gv="$(runtime_stale_graph_version '{"reason":"stale_graph","graph_version":45}')" && rcx=0 || rcx=$?
|
|
519
|
+
[ "$rcx" = 0 ] && [ "$gv" = 45 ] \
|
|
520
|
+
&& echo "OK stale: reason en raiz (forma legacy)" \
|
|
521
|
+
|| { echo "FAIL reason en raiz (rc $rcx, gv '$gv')"; fail=1; }
|
|
522
|
+
gv="$(runtime_stale_graph_version '{"detail":{"error":"graph_version_stale"}}')" && rcx=0 || rcx=$?
|
|
523
|
+
[ "$rcx" = 0 ] && [ -z "$gv" ] \
|
|
524
|
+
&& echo "OK stale sin graph_version -> version vacia, sigue siendo stale" \
|
|
525
|
+
|| { echo "FAIL stale sin version (rc $rcx, gv '$gv')"; fail=1; }
|
|
526
|
+
runtime_stale_graph_version '{"detail":"el client_event_id ya identifica otro agregado"}' >/dev/null && rcx=0 || rcx=$?
|
|
527
|
+
[ "$rcx" = 1 ] && echo "OK un 409 con detail string NO es grafo rancio" \
|
|
528
|
+
|| { echo "FAIL detail string tratado como stale (rc $rcx)"; fail=1; }
|
|
529
|
+
runtime_stale_graph_version '{"detail":{"error":"context_drift"}}' >/dev/null && rcx=0 || rcx=$?
|
|
530
|
+
[ "$rcx" = 1 ] && echo "OK otro error anidado NO es grafo rancio" \
|
|
531
|
+
|| { echo "FAIL otro error tratado como stale (rc $rcx)"; fail=1; }
|
|
532
|
+
runtime_stale_graph_version 'esto no es json' >/dev/null && rcx=0 || rcx=$?
|
|
533
|
+
[ "$rcx" = 1 ] && echo "OK respuesta ilegible NO es grafo rancio" \
|
|
534
|
+
|| { echo "FAIL respuesta ilegible tratada como stale (rc $rcx)"; fail=1; }
|
|
535
|
+
|
|
536
|
+
# 409 de grafo rancio en el carril directo: se anota el desfase y la petición SIGUE en la cola
|
|
537
|
+
# (la refresca el heartbeat, #61), no se pierde ni se aparta a la primera. El cuerpo es el REAL
|
|
538
|
+
# del hub — con la forma plana este bloque pasaba en verde contra un contrato inexistente.
|
|
495
539
|
cat > "$TMP/routes-d.json" <<'JSON'
|
|
496
|
-
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"
|
|
540
|
+
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"detail": {"error": "graph_version_stale", "message": "el grafo avanzó", "graph_version": 60}}},
|
|
497
541
|
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
498
542
|
JSON
|
|
499
543
|
runtime_graph_clear_stale
|
|
@@ -580,6 +624,8 @@ print(json.load(open('$rej12')).get('rejected_reason',''))" 2>/dev/null | grep -
|
|
|
580
624
|
|| echo "OK la razon del rechazo no inventa un desfase de grafo"
|
|
581
625
|
|
|
582
626
|
# [Minor 6] "3 rechazos CONSECUTIVOS" es literal: un transitorio intercalado reinicia la racha.
|
|
627
|
+
# Este bloque usa a propósito la forma PLANA (`reason: stale_graph`): es la compatibilidad hacia
|
|
628
|
+
# atrás con una instancia anterior al contrato, y tiene que seguir entendiéndose.
|
|
583
629
|
cat > "$TMP/routes-d.json" <<'JSON'
|
|
584
630
|
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 70}},
|
|
585
631
|
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|