@trycore/spec-build-harness 0.14.2 → 0.16.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 +1 -1
- package/INSTALL.md +1 -1
- package/METODOLOGIA.md +15 -4
- package/README.md +43 -6
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +3 -0
- package/commands/build/front.md +8 -4
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +11 -2
- package/docs/getting-started.md +1 -1
- package/docs/hooks.md +4 -2
- package/docs/runtime/guia-modo-dual-y-migracion.md +79 -3
- package/docs/runtime/protocolo-cliente-runtime.md +66 -7
- package/hooks/build/lib/config.sh +56 -8
- package/hooks/build/lib/harness-meta.sh +72 -0
- package/hooks/build/lib/runtime-client.sh +120 -14
- package/hooks/build/lib/runtime-ops.sh +160 -27
- package/hooks/build/release-ops.sh +38 -12
- package/hooks/build/session-start.sh +5 -61
- package/hooks/build/slice-ops.sh +615 -41
- package/package.json +1 -1
- package/scripts/lib/front-plan.py +55 -6
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/test-config.sh +40 -0
- package/scripts/tests/test-front-plan.sh +63 -0
- 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
- package/scripts/tests/test-worktree-identity.sh +207 -0
- package/skills/managing-parallel-front/SKILL.md +35 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trycore/spec-build-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -5,9 +5,19 @@
|
|
|
5
5
|
(conjunto disjunto + merge_order) que un humano aprueba al abrir el front en la consola del
|
|
6
6
|
hub. No llama al runtime ni lee el estado: entra JSON por stdin, sale JSON por stdout.
|
|
7
7
|
|
|
8
|
-
stdin: JSON [{"epica","layer","files_scope":[glob]}]
|
|
8
|
+
stdin: JSON [{"epica","layer","files_scope":[glob],"depends_on":[EP-XXX]}]
|
|
9
9
|
stdout: {"selected":[...],"serialized":[{"epica","reason"}],"excluded_foundational":[...]}
|
|
10
10
|
Determinista: orden por 'epica'. Solape de globs por prefijo de directorio o glob idéntico.
|
|
11
|
+
|
|
12
|
+
Las tres compuertas, en orden (METODOLOGIA §5 «Compuertas del front»):
|
|
13
|
+
G1 `layer` fundacional -> excluded_foundational (nunca en paralelo).
|
|
14
|
+
G2a `files_scope` vacío o ausente -> serialized. «Sin `files_scope` declarado, una épica no
|
|
15
|
+
es candidata al front» (METODOLOGIA:343): fail-CLOSED. Que dos épicas sin ámbito
|
|
16
|
+
declarado salieran «disjuntas» era exactamente el fallo que el front no puede permitirse.
|
|
17
|
+
G2b camino en el DAG de `depends_on` con una épica ya seleccionada -> serialized. Dos épicas
|
|
18
|
+
encadenadas no son paralelizables aunque sus ficheros no se toquen: la de abajo construye
|
|
19
|
+
sobre lo que la de arriba todavía no ha mergeado.
|
|
20
|
+
G2c solape de `files_scope` con una épica ya seleccionada -> serialized.
|
|
11
21
|
"""
|
|
12
22
|
import json, sys, fnmatch
|
|
13
23
|
|
|
@@ -24,6 +34,37 @@ def overlap(a, b):
|
|
|
24
34
|
return True
|
|
25
35
|
return False
|
|
26
36
|
|
|
37
|
+
def is_foundational(cand):
|
|
38
|
+
"""G1 normalizada. El bundle del grafo emite `layer` en MAYÚSCULA (graph-bundle.py lo
|
|
39
|
+
`.upper()`a porque el hub lo exige así) mientras METODOLOGIA lo escribe en minúscula: un
|
|
40
|
+
`== "foundational"` a secas dejaba pasar `FOUNDATIONAL` al front, en silencio y justo en la
|
|
41
|
+
compuerta que más caro sale saltarse."""
|
|
42
|
+
layer = cand.get("layer")
|
|
43
|
+
return isinstance(layer, str) and layer.strip().lower() == "foundational"
|
|
44
|
+
|
|
45
|
+
def reachability(cands):
|
|
46
|
+
"""Alcance transitivo de `depends_on` entre las candidatas (clausura por Floyd-Warshall
|
|
47
|
+
booleano; los conjuntos de épicas de un front son de decenas, no de miles).
|
|
48
|
+
|
|
49
|
+
Se calcula sobre TODAS las candidatas, fundacionales incluidas: un camino EP-A → EP-F → EP-B
|
|
50
|
+
encadena A y B aunque la fundacional del medio no entre al front. Las referencias a épicas
|
|
51
|
+
que no vienen en la entrada se ignoran — aquí no se inventa un grafo que no se ha visto."""
|
|
52
|
+
codes = [c.get("epica") for c in cands if isinstance(c.get("epica"), str)]
|
|
53
|
+
known = set(codes)
|
|
54
|
+
reach = {c: set() for c in codes}
|
|
55
|
+
for c in cands:
|
|
56
|
+
code = c.get("epica")
|
|
57
|
+
if code not in known:
|
|
58
|
+
continue
|
|
59
|
+
for dep in c.get("depends_on") or []:
|
|
60
|
+
if isinstance(dep, str) and dep in known and dep != code:
|
|
61
|
+
reach[code].add(dep)
|
|
62
|
+
for k in codes:
|
|
63
|
+
for i in codes:
|
|
64
|
+
if k in reach[i]:
|
|
65
|
+
reach[i] |= reach[k]
|
|
66
|
+
return reach
|
|
67
|
+
|
|
27
68
|
def _empty_result():
|
|
28
69
|
return {"selected": [], "serialized": [], "excluded_foundational": []}
|
|
29
70
|
|
|
@@ -33,15 +74,23 @@ def main():
|
|
|
33
74
|
if not isinstance(cands, list) or not all(isinstance(c, dict) for c in cands):
|
|
34
75
|
raise ValueError("entrada inválida: se esperaba una lista de objetos")
|
|
35
76
|
cands = sorted(cands, key=lambda c: c.get("epica",""))
|
|
36
|
-
|
|
37
|
-
|
|
77
|
+
reach = reachability(cands)
|
|
78
|
+
excluded = [c["epica"] for c in cands if is_foundational(c)]
|
|
79
|
+
pool = [c for c in cands if not is_foundational(c)]
|
|
38
80
|
selected, sel_scopes, serialized = [], [], []
|
|
39
81
|
for c in pool:
|
|
82
|
+
code = c["epica"]
|
|
40
83
|
sc = c.get("files_scope") or []
|
|
41
|
-
if
|
|
42
|
-
serialized.append({"epica":
|
|
84
|
+
if not sc:
|
|
85
|
+
serialized.append({"epica": code,
|
|
86
|
+
"reason": "sin files_scope declarado: no es candidata al front (METODOLOGIA §5 G2)"})
|
|
87
|
+
elif any(code in reach.get(s, ()) or s in reach.get(code, ()) for s in selected):
|
|
88
|
+
serialized.append({"epica": code,
|
|
89
|
+
"reason": "dependencia en el grafo con una épica ya seleccionada"})
|
|
90
|
+
elif any(overlap(sc, s) for s in sel_scopes):
|
|
91
|
+
serialized.append({"epica": code, "reason": "solape de files_scope con épica seleccionada"})
|
|
43
92
|
else:
|
|
44
|
-
selected.append(
|
|
93
|
+
selected.append(code); sel_scopes.append(sc)
|
|
45
94
|
print(json.dumps({"selected":selected,"serialized":serialized,"excluded_foundational":excluded}))
|
|
46
95
|
except Exception:
|
|
47
96
|
print(json.dumps(_empty_result()))
|
|
@@ -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",
|
package/scripts/smoke-test.sh
CHANGED
|
@@ -5,7 +5,7 @@ DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
5
5
|
FAILED=0
|
|
6
6
|
|
|
7
7
|
echo "── Tests del motor de contexto y front ──"
|
|
8
|
-
for t in test-schema.sh test-config.sh test-runtime-client.sh test-hooks-runtime.sh test-skill-ops.sh test-reconciler.sh test-context-monitor.sh test-front-plan.sh test-install.sh; do
|
|
8
|
+
for t in test-schema.sh test-config.sh test-runtime-client.sh test-hooks-runtime.sh test-skill-ops.sh test-worktree-identity.sh test-reconciler.sh test-context-monitor.sh test-front-plan.sh test-install.sh; do
|
|
9
9
|
if bash "$DIR/tests/$t"; then echo "smoke: $t OK"; else echo "smoke: $t FALLÓ"; FAILED=1; fi
|
|
10
10
|
done
|
|
11
11
|
|
|
@@ -63,4 +63,44 @@ got="$(cd "$BARE" && CLAUDE_PROJECT_DIR="$BARE" config_root)"
|
|
|
63
63
|
[ "$got" = "$BARE" ] && echo "OK config_root sin estado devuelve el candidato" \
|
|
64
64
|
|| { echo "FAIL config_root sin estado ($got)"; fail=1; }
|
|
65
65
|
|
|
66
|
+
# ── [#74] Señal de identidad heredada ─────────────────────────────────────────
|
|
67
|
+
# El fallback de arriba es correcto para LEER y veneno para ESCRIBIR: sin una señal
|
|
68
|
+
# que lo distinga, un worktree sin credenciales propias firma sus actos con la
|
|
69
|
+
# identidad del clon principal y nadie se entera. Estos tres helpers son esa señal.
|
|
70
|
+
|
|
71
|
+
got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_candidate_root)"
|
|
72
|
+
WTP="$(cd "$WT" && pwd -P)"
|
|
73
|
+
[ "$got" = "$WTP" ] && echo "OK config_candidate_root devuelve ESTE árbol, sin fallback" \
|
|
74
|
+
|| { echo "FAIL config_candidate_root ($got, esperado $WTP)"; fail=1; }
|
|
75
|
+
|
|
76
|
+
got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_main_clone_root)"
|
|
77
|
+
[ "$got" = "$MAIN" ] && echo "OK config_main_clone_root ve el clon principal desde el worktree" \
|
|
78
|
+
|| { echo "FAIL config_main_clone_root ($got, esperado $MAIN)"; fail=1; }
|
|
79
|
+
|
|
80
|
+
(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_is_inherited_root) \
|
|
81
|
+
&& echo "OK config_is_inherited_root detecta el worktree sin credenciales propias" \
|
|
82
|
+
|| { echo "FAIL config_is_inherited_root no detecta la herencia"; fail=1; }
|
|
83
|
+
|
|
84
|
+
# El clon principal NUNCA hereda: es el dueño del estado.
|
|
85
|
+
(cd "$MAIN" && CLAUDE_PROJECT_DIR="$MAIN" config_is_inherited_root) \
|
|
86
|
+
&& { echo "FAIL config_is_inherited_root marca herencia en el clon principal"; fail=1; } \
|
|
87
|
+
|| echo "OK config_is_inherited_root no marca al clon principal"
|
|
88
|
+
|
|
89
|
+
# Un proyecto sin arnés tampoco: no hay identidad que heredar.
|
|
90
|
+
(cd "$BARE" && CLAUDE_PROJECT_DIR="$BARE" config_is_inherited_root) \
|
|
91
|
+
&& { echo "FAIL config_is_inherited_root marca herencia sin estado en ninguna parte"; fail=1; } \
|
|
92
|
+
|| echo "OK config_is_inherited_root no marca un proyecto sin arnés"
|
|
93
|
+
|
|
94
|
+
# Y en cuanto el worktree tiene credenciales PROPIAS, deja de heredar — sin tocar nada
|
|
95
|
+
# más: es exactamente lo que `slice-ops.sh register` deja hecho.
|
|
96
|
+
mkdir -p "$WT/.claude/state"
|
|
97
|
+
echo '{"runtime_url":"http://localhost","agent_key":"ak-wt"}' > "$WT/.claude/state/runtime.credentials"
|
|
98
|
+
(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_is_inherited_root) \
|
|
99
|
+
&& { echo "FAIL sigue heredando con credenciales propias"; fail=1; } \
|
|
100
|
+
|| echo "OK con credenciales propias el worktree deja de heredar"
|
|
101
|
+
got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_root)"
|
|
102
|
+
[ "$got" = "$WT" ] && echo "OK config_root ancla en el worktree ya registrado" \
|
|
103
|
+
|| { echo "FAIL config_root tras registrar ($got, esperado $WT)"; fail=1; }
|
|
104
|
+
rm -f "$WT/.claude/state/runtime.credentials"
|
|
105
|
+
|
|
66
106
|
exit $fail
|
|
@@ -25,6 +25,59 @@ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
|
25
25
|
assert d['selected']==['EP-010'] and [m['epica'] for m in d['serialized']]==['EP-011'], d
|
|
26
26
|
print('OK front-plan mid-wildcard overlap serializado')" || { echo "FAIL mid-wildcard overlap"; fail=1; }
|
|
27
27
|
|
|
28
|
+
# ── [#74] G1 normalizada: el bundle del grafo emite `layer` en MAYÚSCULA ─────────────
|
|
29
|
+
# graph-bundle.py hace `.upper()` porque el hub lo exige así; front-plan comparaba contra
|
|
30
|
+
# "foundational" en minúscula. La exclusión G1 no disparaba, en silencio, justo en la
|
|
31
|
+
# compuerta que más caro sale saltarse.
|
|
32
|
+
out="$(run '[{"epica":"EP-001","layer":"FOUNDATIONAL","files_scope":["src/**"]},
|
|
33
|
+
{"epica":"EP-002","layer":"BUSINESS","files_scope":["src/a/**"]}]')"
|
|
34
|
+
echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
35
|
+
assert d['excluded_foundational']==['EP-001'], d
|
|
36
|
+
assert d['selected']==['EP-002'], d
|
|
37
|
+
print('OK front-plan G1 con layer en MAYÚSCULA')" || { echo "FAIL G1 mayúscula"; fail=1; }
|
|
38
|
+
|
|
39
|
+
# ── [#74] G2 fail-CLOSED sin files_scope ─────────────────────────────────────────────
|
|
40
|
+
# METODOLOGIA:343: «sin `files_scope` declarado, una épica no es candidata al front». El
|
|
41
|
+
# código hacía lo contrario: con la lista vacía, `overlap([], …)` es falso y dos épicas sin
|
|
42
|
+
# ámbito salían como disjuntas.
|
|
43
|
+
out="$(run '[{"epica":"EP-020","layer":"business","files_scope":[]},
|
|
44
|
+
{"epica":"EP-021","layer":"business"},
|
|
45
|
+
{"epica":"EP-022","layer":"business","files_scope":["src/c/**"]}]')"
|
|
46
|
+
echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
47
|
+
assert d['selected']==['EP-022'], d
|
|
48
|
+
assert {m['epica'] for m in d['serialized']}=={'EP-020','EP-021'}, d
|
|
49
|
+
assert all('files_scope' in m['reason'] for m in d['serialized']), d
|
|
50
|
+
print('OK front-plan sin files_scope -> serialized (fail-closed)')" || { echo "FAIL fail-closed sin files_scope"; fail=1; }
|
|
51
|
+
|
|
52
|
+
# ── [#74] G2 por camino en el DAG de `depends_on` ────────────────────────────────────
|
|
53
|
+
# Ficheros disjuntos no bastan: si EP-031 depende de EP-030, la de abajo construye sobre lo
|
|
54
|
+
# que la de arriba todavía no ha mergeado. La transitividad cuenta, y el camino vale aunque
|
|
55
|
+
# pase por una fundacional que no entra al front.
|
|
56
|
+
out="$(run '[{"epica":"EP-030","layer":"business","files_scope":["src/x/**"]},
|
|
57
|
+
{"epica":"EP-031","layer":"business","files_scope":["src/y/**"],"depends_on":["EP-030"]},
|
|
58
|
+
{"epica":"EP-032","layer":"business","files_scope":["src/z/**"],"depends_on":["EP-031"]},
|
|
59
|
+
{"epica":"EP-033","layer":"business","files_scope":["src/w/**"],"depends_on":["EP-099"]}]')"
|
|
60
|
+
echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
61
|
+
assert d['selected']==['EP-030','EP-033'], d
|
|
62
|
+
assert {m['epica'] for m in d['serialized']}=={'EP-031','EP-032'}, d
|
|
63
|
+
assert all('grafo' in m['reason'] for m in d['serialized']), d
|
|
64
|
+
print('OK front-plan serializa el camino transitivo del DAG')" || { echo "FAIL DAG depends_on"; fail=1; }
|
|
65
|
+
|
|
66
|
+
out="$(run '[{"epica":"EP-040","layer":"business","files_scope":["src/x/**"]},
|
|
67
|
+
{"epica":"EP-041","layer":"FOUNDATIONAL","files_scope":["src/f/**"],"depends_on":["EP-040"]},
|
|
68
|
+
{"epica":"EP-042","layer":"business","files_scope":["src/z/**"],"depends_on":["EP-041"]}]')"
|
|
69
|
+
echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
70
|
+
assert d['selected']==['EP-040'], d
|
|
71
|
+
assert [m['epica'] for m in d['serialized']]==['EP-042'], d
|
|
72
|
+
print('OK front-plan ve el camino que pasa por una fundacional excluida')" || { echo "FAIL DAG a través de fundacional"; fail=1; }
|
|
73
|
+
|
|
74
|
+
# Sin `depends_on` declarado nada cambia: el campo es opcional, no se inventa un grafo.
|
|
75
|
+
out="$(run '[{"epica":"EP-050","layer":"business","files_scope":["src/p/**"]},
|
|
76
|
+
{"epica":"EP-051","layer":"business","files_scope":["src/q/**"]}]')"
|
|
77
|
+
echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
78
|
+
assert d['selected']==['EP-050','EP-051'] and d['serialized']==[], d
|
|
79
|
+
print('OK front-plan sin depends_on no serializa de más')" || { echo "FAIL sin depends_on"; fail=1; }
|
|
80
|
+
|
|
28
81
|
SK="$ROOT/skills/managing-parallel-front/SKILL.md"
|
|
29
82
|
{ [ -f "$SK" ] && grep -q 'git worktree add' "$SK" && grep -q 'front-plan.py' "$SK" \
|
|
30
83
|
&& grep -q 'merge_order' "$SK" && grep -q 'foundational' "$SK"; } \
|
|
@@ -35,4 +88,14 @@ SK="$ROOT/skills/managing-parallel-front/SKILL.md"
|
|
|
35
88
|
grep -q 'files_scope' "$ROOT/skills/building-a-slice/references/dor.md" && echo "OK dor files_scope" || { echo "FAIL dor files_scope"; fail=1; }
|
|
36
89
|
grep -q 'parallel_front' "$ROOT/agents/build/build-orchestrator.md" && echo "OK orquestador front" || { echo "FAIL orquestador front"; fail=1; }
|
|
37
90
|
|
|
91
|
+
# [#74] La skill decía «mismo token de proyecto»: el hub lo refuta (el `agent_key` es el
|
|
92
|
+
# `agent_id` grabado en el token por el ADMIN). Un token repetido = un solo agente.
|
|
93
|
+
grep -q 'mismo token de proyecto' "$SK" \
|
|
94
|
+
&& { echo "FAIL la skill sigue diciendo «mismo token de proyecto»"; fail=1; } \
|
|
95
|
+
|| echo "OK la skill ya no manda reusar el token de proyecto"
|
|
96
|
+
grep -q 'slice-ops.sh register' "$SK" && echo "OK la skill manda dar de alta el worktree con register" \
|
|
97
|
+
|| { echo "FAIL la skill no menciona register"; fail=1; }
|
|
98
|
+
grep -q 'depends_on' "$SK" && echo "OK la skill pide depends_on entre las candidatas" \
|
|
99
|
+
|| { echo "FAIL la skill no pide depends_on"; fail=1; }
|
|
100
|
+
|
|
38
101
|
exit $fail
|
|
@@ -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": []}}}
|