@trycore/spec-build-harness 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/VERSION +1 -1
- package/commands/build/escalate.md +1 -1
- package/commands/build/onboard.md +20 -4
- package/dist/cli.js +11 -1
- package/dist/commands/migrate.js +107 -2
- package/dist/lib/normalize.js +925 -78
- package/dist/lib/state-bundle.js +112 -8
- package/docs/runtime/guia-modo-dual-y-migracion.md +9 -2
- package/docs/runtime/protocolo-cliente-runtime.md +29 -19
- package/hooks/build/event-emitter.sh +9 -52
- package/hooks/build/lib/runtime-client.sh +183 -23
- package/hooks/build/lib/runtime-ops.sh +14 -8
- package/hooks/build/lib/state-io.sh +0 -0
- package/hooks/build/release-ops.sh +16 -9
- package/hooks/build/slice-ops.sh +152 -81
- package/package.json +1 -1
- package/scripts/check-runtime-purity.sh +0 -0
- package/scripts/denylist.txt +4 -0
- package/scripts/lib/graph-bundle.py +68 -20
- package/scripts/tests/test-baseline-verdict.sh +0 -0
- package/scripts/tests/test-hooks-runtime.sh +13 -38
- package/scripts/tests/test-install.sh +46 -0
- package/scripts/tests/test-skill-ops.sh +578 -58
- package/skills/building-a-slice/references/runtime-protocol.md +1 -1
- package/skills/releasing-a-version/SKILL.md +2 -1
|
@@ -6,13 +6,21 @@
|
|
|
6
6
|
**prepara y valida** el bundle en local — nunca lo sube — y `/build:onboard` se lo entrega a
|
|
7
7
|
una persona.
|
|
8
8
|
|
|
9
|
+
[issue #41] `epics` sale en el FORMATO EXACTO del hub (`GraphService.import_graph`,
|
|
10
|
+
`validate_graph`): `layer` en MAYÚSCULA (`VALID_LAYERS = {FOUNDATIONAL, BUSINESS}` — un layer
|
|
11
|
+
inválido rechaza el grafo ENTERO, GraphIntegrityError), historias con `code` + `title`
|
|
12
|
+
(el hub hace `story_in["code"]`), `title` obligatorio por épica e historia, y `release_line`
|
|
13
|
+
como string POR ÉPICA (las `release_lines` de la entrada se pliegan aquí). Así el mismo
|
|
14
|
+
objeto sirve para la pantalla de import del grafo y para embeberse como sección `graph`
|
|
15
|
+
del bundle de estado (`trycore-build migrate`).
|
|
16
|
+
|
|
9
17
|
Uso:
|
|
10
18
|
python3 graph-bundle.py < epics.json > bundle.json # avisos por stderr
|
|
11
19
|
|
|
12
|
-
Entrada (stdin, JSON):
|
|
20
|
+
Entrada (stdin, JSON — formato de discovery, sin cambios):
|
|
13
21
|
{"project_ref": "…",
|
|
14
22
|
"epics": [{"code","title","layer","files_scope":[],"depends_on":[],
|
|
15
|
-
"stories":[{"id","title"}]}],
|
|
23
|
+
"stories":[{"id"|"code","title"}]}],
|
|
16
24
|
"release_lines": [{"id","epics":[]}]}
|
|
17
25
|
|
|
18
26
|
Salida (stdout, JSON): el bundle normalizado y DETERMINISTA (orden estable por código);
|
|
@@ -25,7 +33,10 @@ import json
|
|
|
25
33
|
import sys
|
|
26
34
|
import datetime
|
|
27
35
|
|
|
28
|
-
BUNDLE_VERSION =
|
|
36
|
+
BUNDLE_VERSION = 2
|
|
37
|
+
|
|
38
|
+
# El contrato del hub: `validate_graph` rechaza el grafo ENTERO si un layer no está aquí.
|
|
39
|
+
VALID_LAYERS = {"FOUNDATIONAL", "BUSINESS"}
|
|
29
40
|
|
|
30
41
|
|
|
31
42
|
def norm_list(v):
|
|
@@ -55,20 +66,52 @@ def main():
|
|
|
55
66
|
if code in epicas:
|
|
56
67
|
avisos.append("código duplicado %s: se conserva la PRIMERA aparición" % code)
|
|
57
68
|
continue
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
# `title` es obligatorio en el hub (EpicIn, min_length=1): cae al código.
|
|
70
|
+
titulo = e.get("title")
|
|
71
|
+
if not isinstance(titulo, str) or not titulo:
|
|
72
|
+
avisos.append("%s sin `title`: se usa el código (el hub lo exige)" % code)
|
|
73
|
+
titulo = code
|
|
74
|
+
# `layer` viaja en MAYÚSCULA; ausente o inválido, el import del hub
|
|
75
|
+
# rechaza el grafo ENTERO (GraphIntegrityError) — se avisa, no se inventa.
|
|
76
|
+
layer = e.get("layer")
|
|
77
|
+
if isinstance(layer, str) and layer:
|
|
78
|
+
layer = layer.upper()
|
|
79
|
+
if layer not in VALID_LAYERS:
|
|
80
|
+
avisos.append("%s con layer %r fuera de FOUNDATIONAL|BUSINESS: "
|
|
81
|
+
"el hub rechazará el grafo entero" % (code, e.get("layer")))
|
|
82
|
+
else:
|
|
83
|
+
layer = None
|
|
84
|
+
avisos.append("%s sin `layer`: el hub rechazará el grafo entero "
|
|
85
|
+
"(FOUNDATIONAL|BUSINESS)" % code)
|
|
60
86
|
historias = []
|
|
61
87
|
for h in e.get("stories") or []:
|
|
62
|
-
if isinstance(h, dict)
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
if not isinstance(h, dict):
|
|
89
|
+
continue
|
|
90
|
+
# El hub hace `story_in["code"]`; la entrada de discovery trae `id`.
|
|
91
|
+
hcode = h.get("code") if isinstance(h.get("code"), str) else h.get("id")
|
|
92
|
+
if not isinstance(hcode, str) or not hcode:
|
|
93
|
+
continue
|
|
94
|
+
htitle = h.get("title")
|
|
95
|
+
if not isinstance(htitle, str) or not htitle:
|
|
96
|
+
avisos.append("%s: historia %s sin `title`: se usa el código" % (code, hcode))
|
|
97
|
+
htitle = hcode
|
|
98
|
+
historias.append({"code": hcode, "title": htitle})
|
|
99
|
+
epica = {
|
|
65
100
|
"code": code,
|
|
66
|
-
"title":
|
|
67
|
-
"layer":
|
|
101
|
+
"title": titulo,
|
|
102
|
+
"layer": layer,
|
|
68
103
|
"files_scope": sorted(norm_list(e.get("files_scope"))),
|
|
69
104
|
"depends_on": sorted(norm_list(e.get("depends_on"))),
|
|
70
|
-
"
|
|
105
|
+
"release_line": e.get("release_line")
|
|
106
|
+
if isinstance(e.get("release_line"), str) else None,
|
|
107
|
+
"stories": sorted(historias, key=lambda h: h["code"]),
|
|
71
108
|
}
|
|
109
|
+
# Opcionales del contrato del hub: pasan tal cual si vienen bien tipados.
|
|
110
|
+
if isinstance(e.get("priority"), int):
|
|
111
|
+
epica["priority"] = e["priority"]
|
|
112
|
+
if isinstance(e.get("docs_ref"), str):
|
|
113
|
+
epica["docs_ref"] = e["docs_ref"]
|
|
114
|
+
epicas[code] = epica
|
|
72
115
|
|
|
73
116
|
for code, e in epicas.items():
|
|
74
117
|
for dep in e["depends_on"]:
|
|
@@ -100,16 +143,22 @@ def main():
|
|
|
100
143
|
estado[nodo] = 2
|
|
101
144
|
pila.pop()
|
|
102
145
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
146
|
+
# Las `release_lines` de la entrada se PLIEGAN al `release_line` (string) por
|
|
147
|
+
# épica que el hub persiste — gana la primera asignación (orden estable por id).
|
|
148
|
+
lineas = [r for r in (raw.get("release_lines") or [])
|
|
149
|
+
if isinstance(r, dict) and isinstance(r.get("id"), str)]
|
|
150
|
+
lineas.sort(key=lambda r: r["id"])
|
|
151
|
+
for r in lineas:
|
|
152
|
+
for c in norm_list(r.get("epics")):
|
|
109
153
|
if c not in epicas:
|
|
110
154
|
avisos.append("la línea %s referencia %s, que no está en el grafo" % (r["id"], c))
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
continue
|
|
156
|
+
if epicas[c]["release_line"] is None:
|
|
157
|
+
epicas[c]["release_line"] = r["id"]
|
|
158
|
+
elif epicas[c]["release_line"] != r["id"]:
|
|
159
|
+
avisos.append("%s ya está en la línea %s: se ignora %s "
|
|
160
|
+
"(el hub guarda UNA línea por épica)"
|
|
161
|
+
% (c, epicas[c]["release_line"], r["id"]))
|
|
113
162
|
|
|
114
163
|
bundle = {
|
|
115
164
|
"bundle_version": BUNDLE_VERSION,
|
|
@@ -119,7 +168,6 @@ def main():
|
|
|
119
168
|
.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
120
169
|
"generated_by": "trycore-build-harness/graph-bundle.py",
|
|
121
170
|
"epics": [epicas[c] for c in sorted(epicas)],
|
|
122
|
-
"release_lines": lineas,
|
|
123
171
|
"warnings": avisos,
|
|
124
172
|
}
|
|
125
173
|
json.dump(bundle, sys.stdout, indent=2, ensure_ascii=False, sort_keys=False)
|
|
File without changes
|
|
@@ -380,44 +380,19 @@ echo '{"tool_name":"Edit","tool_input":{"file_path":"'"$PROJ"'/src/a.ts"}}' \
|
|
|
380
380
|
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
381
381
|
[ "$n" = 0 ] && echo "OK event-emitter no-op en legacy" || { echo "FAIL event-emitter encolo en legacy ($n)"; fail=1; }
|
|
382
382
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
"
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
echo
|
|
396
|
-
| bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
|
|
397
|
-
body="$(cat "$PROJ"/.claude/state/outbox/*.json 2>/dev/null)"
|
|
398
|
-
echo "$body" | grep -q 'SECRETO' && { echo "FAIL event-emitter filtra el comando completo"; fail=1; } || echo "OK event-emitter no filtra el comando"
|
|
399
|
-
echo "$body" | grep -q '"argv0": *"curl"' && echo "OK event-emitter reporta solo argv0" || { echo "FAIL argv0 ($body)"; fail=1; }
|
|
400
|
-
|
|
401
|
-
# Bash: secreto en asignación de entorno inline al frente del comando (`VAR=valor cmd ...`)
|
|
402
|
-
# tampoco debe filtrarse — argv0 debe ser el binario, no la asignación (protocolo §8).
|
|
403
|
-
rm -f "$PROJ"/.claude/state/outbox/*.json
|
|
404
|
-
echo '{"tool_name":"Bash","tool_input":{"command":"API_TOKEN=sk-live-XYZ curl https://api.example.com"}}' \
|
|
405
|
-
| bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
|
|
406
|
-
body="$(cat "$PROJ"/.claude/state/outbox/*.json 2>/dev/null)"
|
|
407
|
-
echo "$body" | grep -q 'sk-live-XYZ' && { echo "FAIL event-emitter filtra secreto en asignacion de entorno"; fail=1; } || echo "OK event-emitter no filtra secreto en asignacion de entorno"
|
|
408
|
-
echo "$body" | grep -q 'API_TOKEN' && { echo "FAIL event-emitter filtra nombre de variable de entorno"; fail=1; } || echo "OK event-emitter no filtra nombre de variable de entorno"
|
|
409
|
-
echo "$body" | grep -q '"argv0": *"curl"' && echo "OK event-emitter salta asignacion de entorno y reporta binario" || { echo "FAIL argv0 con asignacion de entorno ($body)"; fail=1; }
|
|
410
|
-
|
|
411
|
-
# Bash: el valor citado de la asignación de entorno inline lleva un espacio interno
|
|
412
|
-
# (`KEY="a b" cmd`) — una tokenización naïve por espacios trocearía el valor citado y el
|
|
413
|
-
# segundo fragmento (parte del secreto) se colaría como argv0. shlex debe tratarlo como
|
|
414
|
-
# un solo token y seguir saltándolo.
|
|
415
|
-
rm -f "$PROJ"/.claude/state/outbox/*.json
|
|
416
|
-
echo '{"tool_name":"Bash","tool_input":{"command":"AWS_SECRET_ACCESS_KEY=\"abc def-secretpart\" aws s3 ls"}}' \
|
|
417
|
-
| bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
|
|
418
|
-
body="$(cat "$PROJ"/.claude/state/outbox/*.json 2>/dev/null)"
|
|
419
|
-
echo "$body" | grep -q 'def-secretpart' && { echo "FAIL event-emitter filtra fragmento de secreto citado con espacio"; fail=1; } || echo "OK event-emitter no filtra fragmento de secreto citado con espacio"
|
|
420
|
-
echo "$body" | grep -q '"argv0": *"aws"' && echo "OK event-emitter reporta binario con asignacion de entorno citada" || { echo "FAIL argv0 con asignacion citada ($body)"; fail=1; }
|
|
383
|
+
# [#44] `tool_use_recorded` no existe en el catálogo v2 del hub: el hook YA NO encola
|
|
384
|
+
# nada en ningún modo (era ruido garantizado en outbox/rejected/). Con ello desaparece
|
|
385
|
+
# también toda superficie de fuga de secretos por payload (los casos de argv0/asignación
|
|
386
|
+
# de entorno de v0.10.x quedan cubiertos por «no se encola nada»). Si el hub añade el
|
|
387
|
+
# tipo al catálogo, se reactiva la emisión y estos casos vuelven en su forma granular.
|
|
388
|
+
for tin in '{"tool_name":"Edit","tool_input":{"file_path":"'"$PROJ"'/src/a.ts"}}' \
|
|
389
|
+
'{"tool_name":"Bash","tool_input":{"command":"curl -H \"Authorization: Bearer SECRETO\" https://x"}}' \
|
|
390
|
+
'{"tool_name":"Bash","tool_input":{"command":"API_TOKEN=sk-live-XYZ curl https://api.example.com"}}'; do
|
|
391
|
+
echo "$tin" | bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
|
|
392
|
+
done
|
|
393
|
+
n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
394
|
+
[ "$n" = 0 ] && echo "OK event-emitter en runtime no encola tool_use_recorded (fuera del catálogo v2, #44)" \
|
|
395
|
+
|| { echo "FAIL event-emitter encolo un tipo fuera de catálogo ($n)"; fail=1; }
|
|
421
396
|
|
|
422
397
|
# Sin slice reclamado: no se encola nada de ámbito slice (el servidor lo rechazaría).
|
|
423
398
|
rm -f "$PROJ"/.claude/state/outbox/*.json
|
|
@@ -231,4 +231,50 @@ else
|
|
|
231
231
|
fail=1
|
|
232
232
|
fi
|
|
233
233
|
|
|
234
|
+
# 8) `migrate --pre-harness` (issue #42): épicas construidas antes del arnés se
|
|
235
|
+
# importan archivadas SIN gates y con acta; el conflicto con un slice
|
|
236
|
+
# existente se rechaza.
|
|
237
|
+
cat > "$TMP/.pre-harness.json" <<'EOF'
|
|
238
|
+
{"epics": [{"code": "EP-001", "note": "MVP en producción"}], "as_of": "2026-04-01T00:00:00Z"}
|
|
239
|
+
EOF
|
|
240
|
+
node "$ROOT/dist/cli.js" migrate "$TMP" --project-ref acme --pre-harness "$TMP/.pre-harness.json" >"$TMP/.migrate-pre.log" 2>&1
|
|
241
|
+
rc=$?
|
|
242
|
+
if [ $rc -eq 0 ] && python3 -c "
|
|
243
|
+
import json
|
|
244
|
+
d=json.load(open('$BUNDLE'))
|
|
245
|
+
assert len(d['history'])==1, d['history']
|
|
246
|
+
entry=d['history'][0]
|
|
247
|
+
assert entry['epic_code']=='EP-001', entry
|
|
248
|
+
types=[e['event_type'] for e in entry['events']]
|
|
249
|
+
assert 'gate_verdict' not in types, types
|
|
250
|
+
assert types[0]=='slice_opened' and types[-1]=='slice_archived', types
|
|
251
|
+
assert types.count('phase_advanced')==9, types
|
|
252
|
+
notes=[e['payload']['note'] for e in entry['events'] if e['event_type']=='progress_noted']
|
|
253
|
+
assert any('pre-arnés' in n and 'MVP en producción' in n for n in notes), notes
|
|
254
|
+
" 2>/dev/null; then
|
|
255
|
+
echo "OK install: migrate --pre-harness importa la épica archivada sin gates y con acta"
|
|
256
|
+
else
|
|
257
|
+
echo "FAIL install: migrate --pre-harness no generó la secuencia esperada (rc=$rc)"
|
|
258
|
+
cat "$TMP/.migrate-pre.log"
|
|
259
|
+
fail=1
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
# Conflicto: la misma épica ya con slice en history[] debe rechazarse.
|
|
263
|
+
python3 -c "
|
|
264
|
+
import json
|
|
265
|
+
p='$TMP/.claude/state/build-state.json'
|
|
266
|
+
d=json.load(open(p))
|
|
267
|
+
d['history']=[{'epica':'EP-001','phase':'archived','gates':{},'updated_at':'2026-08-14T00:00:00Z'}]
|
|
268
|
+
json.dump(d, open(p,'w'))
|
|
269
|
+
"
|
|
270
|
+
node "$ROOT/dist/cli.js" migrate "$TMP" --project-ref acme --pre-harness "$TMP/.pre-harness.json" >"$TMP/.migrate-conf.log" 2>&1
|
|
271
|
+
rc=$?
|
|
272
|
+
if [ $rc -ne 0 ] && grep -q "pre-arnés" "$TMP/.migrate-conf.log"; then
|
|
273
|
+
echo "OK install: migrate --pre-harness rechaza la épica que ya tiene slice en history[]"
|
|
274
|
+
else
|
|
275
|
+
echo "FAIL install: el conflicto pre-arnés/slice existente no se rechazó (rc=$rc)"
|
|
276
|
+
cat "$TMP/.migrate-conf.log"
|
|
277
|
+
fail=1
|
|
278
|
+
fi
|
|
279
|
+
|
|
234
280
|
exit $fail
|