@trycore/spec-build-harness 0.11.1 → 0.13.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/VERSION +1 -1
- package/commands/build/onboard.md +25 -8
- package/dist/commands/doctor.js +12 -1
- package/dist/commands/status.js +9 -1
- package/dist/lib/normalize.js +11 -0
- package/dist/lib/runtime-client.js +34 -0
- package/dist/lib/state-bundle.js +15 -1
- package/docs/runtime/protocolo-cliente-runtime.md +30 -2
- package/hooks/build/lib/runtime-client.sh +42 -5
- package/hooks/build/lib/runtime-ops.sh +64 -0
- package/hooks/build/slice-ops.sh +137 -3
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +165 -1
- package/scripts/tests/test-skill-ops.sh +356 -9
- package/skills/building-a-slice/SKILL.md +10 -0
- package/skills/building-a-slice/references/runtime-protocol.md +7 -2
- package/skills/openspec-archive-change/SKILL.md +4 -1
|
@@ -14,8 +14,17 @@ como string POR ÉPICA (las `release_lines` de la entrada se pliegan aquí). As
|
|
|
14
14
|
objeto sirve para la pantalla de import del grafo y para embeberse como sección `graph`
|
|
15
15
|
del bundle de estado (`trycore-build migrate`).
|
|
16
16
|
|
|
17
|
+
[issue #56] Las dependencias entre épicas viven como PROSA en `docs/03-backlog/epicas.md`
|
|
18
|
+
(`**Depende de**: EP-001 (…)`), y delegar su lectura al modelo las dejó VACÍAS en el piloto
|
|
19
|
+
(41/41 épicas con `depends_on: []`, Lienzo sin una sola arista). `--from-docs` extrae ese
|
|
20
|
+
grafo de forma DETERMINISTA — regex de códigos `EP-…` sobre el campo `Depende de` de cada
|
|
21
|
+
sección de épica, excluyendo la propia — y lo une a lo que traiga el JSON de entrada. Si la
|
|
22
|
+
épica declara el campo estructurado `depende_de: [EP-001, …]`, ese gana sobre la prosa.
|
|
23
|
+
|
|
17
24
|
Uso:
|
|
18
25
|
python3 graph-bundle.py < epics.json > bundle.json # avisos por stderr
|
|
26
|
+
python3 graph-bundle.py --from-docs docs/03-backlog/epicas.md < epics.json > bundle.json
|
|
27
|
+
python3 graph-bundle.py --print-deps docs/03-backlog/epicas.md # solo el grafo extraído
|
|
19
28
|
|
|
20
29
|
Entrada (stdin, JSON — formato de discovery, sin cambios):
|
|
21
30
|
{"project_ref": "…",
|
|
@@ -27,9 +36,12 @@ Salida (stdout, JSON): el bundle normalizado y DETERMINISTA (orden estable por c
|
|
|
27
36
|
los problemas no bloquean: se listan como avisos en stderr y en `warnings` del bundle, con
|
|
28
37
|
el criterio del arnés — nada se pierde en silencio, nada bloquea la preparación.
|
|
29
38
|
|
|
30
|
-
Códigos de salida: 0 = bundle generado (con o sin avisos) · 1 = entrada ilegible
|
|
39
|
+
Códigos de salida: 0 = bundle generado (con o sin avisos) · 1 = entrada ilegible
|
|
40
|
+
(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).
|
|
31
42
|
"""
|
|
32
43
|
import json
|
|
44
|
+
import re
|
|
33
45
|
import sys
|
|
34
46
|
import datetime
|
|
35
47
|
|
|
@@ -43,8 +55,128 @@ def norm_list(v):
|
|
|
43
55
|
return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
|
|
44
56
|
|
|
45
57
|
|
|
58
|
+
# ── Extractor determinista de dependencias desde los docs de discovery [issue #56] ──
|
|
59
|
+
# Códigos de épica: EP-001 y también los compuestos del estilo EP-OR-08.
|
|
60
|
+
RE_CODIGO = re.compile(r"\bEP-(?:[A-Za-z]{1,6}-)?\d{1,4}\b")
|
|
61
|
+
# Encabezado de sección de épica: `## EP-001 — Título` (cualquier nivel).
|
|
62
|
+
RE_ENCABEZADO = re.compile(r"^\s{0,3}(#{1,6})\s+(.*)$")
|
|
63
|
+
# El campo en prosa que escribe la metodología: `**Depende de**: …` (puede ir a mitad de
|
|
64
|
+
# línea, p. ej. `**Anexo A**: Fase 3 · **Depende de**: EP-002`).
|
|
65
|
+
RE_DEPENDE_DE = re.compile(r"depende\s+de", re.IGNORECASE)
|
|
66
|
+
# El campo estructurado (frontmatter o cuerpo): `depende_de: [EP-001, EP-002]`.
|
|
67
|
+
RE_DEPENDE_DE_ESTRUCTURADO = re.compile(r"^\s*(?:-\s*)?depende_de\s*:", re.IGNORECASE)
|
|
68
|
+
# Fin del párrafo del campo: encabezado, regla horizontal, viñeta/cita/tabla o campo nuevo.
|
|
69
|
+
RE_FIN_DE_CAMPO = re.compile(r"^\s*(?:#{1,6}\s|-{3,}\s*$|\*{3,}\s*$|[-*+>|]\s|\*\*)")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extraer_dependencias(texto):
|
|
73
|
+
"""Devuelve ({codigo: [deps ordenadas]}, {códigos con sección}, avisos).
|
|
74
|
+
|
|
75
|
+
Solo entran las épicas que DECLARAN el campo (en prosa o estructurado): "no lo declara"
|
|
76
|
+
y "declara que no depende de nada" son cosas distintas y ninguna se inventa aquí.
|
|
77
|
+
"""
|
|
78
|
+
avisos = []
|
|
79
|
+
lineas = texto.splitlines()
|
|
80
|
+
# (código de la épica, línea donde abre su sección)
|
|
81
|
+
secciones = []
|
|
82
|
+
for i, linea in enumerate(lineas):
|
|
83
|
+
m = RE_ENCABEZADO.match(linea)
|
|
84
|
+
if not m:
|
|
85
|
+
continue
|
|
86
|
+
codigos = RE_CODIGO.findall(m.group(2))
|
|
87
|
+
if codigos:
|
|
88
|
+
secciones.append((codigos[0], i))
|
|
89
|
+
if not secciones:
|
|
90
|
+
avisos.append("no se reconoció ninguna sección de épica en los docs "
|
|
91
|
+
"(se esperaban encabezados del estilo `## EP-001 — Título`)")
|
|
92
|
+
return {}, set(), avisos
|
|
93
|
+
|
|
94
|
+
deps = {}
|
|
95
|
+
for n, (codigo, inicio) in enumerate(secciones):
|
|
96
|
+
fin = secciones[n + 1][1] if n + 1 < len(secciones) else len(lineas)
|
|
97
|
+
cuerpo = lineas[inicio + 1:fin]
|
|
98
|
+
prosa, estructurado = None, None
|
|
99
|
+
for j, linea in enumerate(cuerpo):
|
|
100
|
+
if estructurado is None and RE_DEPENDE_DE_ESTRUCTURADO.match(linea):
|
|
101
|
+
estructurado = RE_CODIGO.findall(linea.split(":", 1)[1])
|
|
102
|
+
continue
|
|
103
|
+
if prosa is not None:
|
|
104
|
+
continue
|
|
105
|
+
m = RE_DEPENDE_DE.search(linea)
|
|
106
|
+
if not m:
|
|
107
|
+
continue
|
|
108
|
+
# El campo puede continuar en las líneas siguientes hasta el fin del párrafo.
|
|
109
|
+
trozos = [linea[m.end():]]
|
|
110
|
+
for siguiente in cuerpo[j + 1:]:
|
|
111
|
+
if not siguiente.strip() or RE_FIN_DE_CAMPO.match(siguiente):
|
|
112
|
+
break
|
|
113
|
+
trozos.append(siguiente)
|
|
114
|
+
prosa = RE_CODIGO.findall(" ".join(trozos))
|
|
115
|
+
# El campo estructurado es determinista: gana sobre la heurística de la prosa.
|
|
116
|
+
encontrado = estructurado if estructurado is not None else prosa
|
|
117
|
+
if encontrado is None:
|
|
118
|
+
continue
|
|
119
|
+
deps[codigo] = sorted({c for c in encontrado if c != codigo})
|
|
120
|
+
return deps, {c for c, _ in secciones}, avisos
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def leer_dependencias_de_docs(ruta):
|
|
124
|
+
"""Lee el fichero de épicas; lanza IOError/OSError si no se puede (rc 1 arriba)."""
|
|
125
|
+
with open(ruta, encoding="utf-8") as fh:
|
|
126
|
+
return extraer_dependencias(fh.read())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
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}
|
|
132
|
+
i = 0
|
|
133
|
+
while i < len(argv):
|
|
134
|
+
arg = argv[i]
|
|
135
|
+
if arg in ("--from-docs", "--print-deps"):
|
|
136
|
+
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
|
137
|
+
return opciones, "%s exige la ruta del fichero de épicas" % arg
|
|
138
|
+
opciones["from_docs" if arg == "--from-docs" else "print_deps"] = argv[i + 1]
|
|
139
|
+
i += 2
|
|
140
|
+
continue
|
|
141
|
+
return opciones, "opción desconocida: %s" % arg
|
|
142
|
+
if opciones["from_docs"] and opciones["print_deps"]:
|
|
143
|
+
# Ignorar una de las dos en silencio es exactamente el error que corrige #56.
|
|
144
|
+
return opciones, "--from-docs y --print-deps son excluyentes"
|
|
145
|
+
return opciones, None
|
|
146
|
+
|
|
147
|
+
|
|
46
148
|
def main():
|
|
47
149
|
avisos = []
|
|
150
|
+
opciones, error = parsear_argumentos(sys.argv[1:])
|
|
151
|
+
if error:
|
|
152
|
+
print("graph-bundle: %s" % error, file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
# `--print-deps`: solo el grafo extraído (diagnóstico; no toca stdin ni arma bundle).
|
|
156
|
+
if opciones["print_deps"]:
|
|
157
|
+
try:
|
|
158
|
+
deps, _, avisos_docs = leer_dependencias_de_docs(opciones["print_deps"])
|
|
159
|
+
except (IOError, OSError) as e:
|
|
160
|
+
print("graph-bundle: no se pudo leer %s: %s" % (opciones["print_deps"], e),
|
|
161
|
+
file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
json.dump(deps, sys.stdout, indent=2, ensure_ascii=False, sort_keys=True)
|
|
164
|
+
sys.stdout.write("\n")
|
|
165
|
+
for a in avisos_docs:
|
|
166
|
+
print("aviso: %s" % a, file=sys.stderr)
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
deps_docs = None
|
|
170
|
+
if opciones["from_docs"]:
|
|
171
|
+
try:
|
|
172
|
+
deps_docs, secciones_docs, avisos_docs = \
|
|
173
|
+
leer_dependencias_de_docs(opciones["from_docs"])
|
|
174
|
+
except (IOError, OSError) as e:
|
|
175
|
+
print("graph-bundle: no se pudo leer %s: %s" % (opciones["from_docs"], e),
|
|
176
|
+
file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
avisos.extend(avisos_docs)
|
|
179
|
+
|
|
48
180
|
try:
|
|
49
181
|
raw = json.load(sys.stdin)
|
|
50
182
|
except Exception as e:
|
|
@@ -113,6 +245,36 @@ def main():
|
|
|
113
245
|
epica["docs_ref"] = e["docs_ref"]
|
|
114
246
|
epicas[code] = epica
|
|
115
247
|
|
|
248
|
+
# [issue #56] Las dependencias de los docs se UNEN a las declaradas: el extractor es la
|
|
249
|
+
# fuente fiable, y lo que ya venía en el JSON no se pierde (se avisa si no está en docs).
|
|
250
|
+
if deps_docs is not None:
|
|
251
|
+
aristas = 0
|
|
252
|
+
for code, e in epicas.items():
|
|
253
|
+
if code not in deps_docs:
|
|
254
|
+
if code in secciones_docs:
|
|
255
|
+
avisos.append("%s tiene sección en %s pero no declara el campo "
|
|
256
|
+
"`Depende de`: sus dependencias quedan como vinieran en el "
|
|
257
|
+
"JSON" % (code, opciones["from_docs"]))
|
|
258
|
+
else:
|
|
259
|
+
avisos.append("%s no aparece como sección de épica en %s: sus "
|
|
260
|
+
"dependencias no se pudieron extraer de los docs"
|
|
261
|
+
% (code, opciones["from_docs"]))
|
|
262
|
+
continue
|
|
263
|
+
solo_json = [d for d in e["depends_on"] if d not in deps_docs[code]]
|
|
264
|
+
if solo_json:
|
|
265
|
+
avisos.append("%s: %s viene del JSON de entrada pero no del campo `Depende de` "
|
|
266
|
+
"de los docs: verifica cuál manda"
|
|
267
|
+
% (code, ", ".join(solo_json)))
|
|
268
|
+
e["depends_on"] = sorted(set(e["depends_on"]) | set(deps_docs[code]))
|
|
269
|
+
aristas += len(e["depends_on"])
|
|
270
|
+
for code in sorted(deps_docs):
|
|
271
|
+
if code not in epicas:
|
|
272
|
+
avisos.append("%s está en %s pero no en el JSON de entrada: el grafo del hub "
|
|
273
|
+
"quedaría sin esa épica" % (code, opciones["from_docs"]))
|
|
274
|
+
print("info: dependencias extraídas de %s -> %d épicas con campo `Depende de`, "
|
|
275
|
+
"%d aristas en el grafo" % (opciones["from_docs"], len(deps_docs), aristas),
|
|
276
|
+
file=sys.stderr)
|
|
277
|
+
|
|
116
278
|
for code, e in epicas.items():
|
|
117
279
|
for dep in e["depends_on"]:
|
|
118
280
|
if dep not in epicas:
|
|
@@ -167,6 +329,8 @@ def main():
|
|
|
167
329
|
"generated_at": datetime.datetime.now(datetime.timezone.utc)
|
|
168
330
|
.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
169
331
|
"generated_by": "trycore-build-harness/graph-bundle.py",
|
|
332
|
+
"depends_on_source": ("docs:%s" % opciones["from_docs"]) if deps_docs is not None
|
|
333
|
+
else "input",
|
|
170
334
|
"epics": [epicas[c] for c in sorted(epicas)],
|
|
171
335
|
"warnings": avisos,
|
|
172
336
|
}
|