@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.
@@ -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 = 1
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
- if not e.get("layer"):
59
- avisos.append("%s sin `layer`: el DoR no podrá aplicar la regla de cimiento" % code)
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) and isinstance(h.get("id"), str):
63
- historias.append({"id": h["id"], "title": h.get("title")})
64
- epicas[code] = {
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": e.get("title"),
67
- "layer": e.get("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
- "stories": sorted(historias, key=lambda h: h["id"]),
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
- lineas = []
104
- for r in raw.get("release_lines") or []:
105
- if not isinstance(r, dict) or not isinstance(r.get("id"), str):
106
- continue
107
- miembros = [c for c in norm_list(r.get("epics"))]
108
- for c in miembros:
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
- lineas.append({"id": r["id"], "epics": miembros})
112
- lineas.sort(key=lambda r: r["id"])
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
- echo '{"tool_name":"Edit","tool_input":{"file_path":"'"$PROJ"'/src/a.ts"}}' \
384
- | bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
385
- ev="$(python3 -c "
386
- import json,glob
387
- files=sorted(p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json'))
388
- d=json.load(open(files[0]))
389
- print(d['type'], d.get('slice_id'), d['payload'].get('path'), d['payload'].get('tool'))
390
- " 2>/dev/null)"
391
- [ "$ev" = "tool_use_recorded sl-11 src/a.ts Edit" ] && echo "OK event-emitter encola ruta relativa con slice_id" || { echo "FAIL evento ($ev)"; fail=1; }
392
-
393
- # Bash: solo el binario, jamás el comando completo (puede llevar secretos protocolo §8).
394
- rm -f "$PROJ"/.claude/state/outbox/*.json
395
- echo '{"tool_name":"Bash","tool_input":{"command":"curl -H \"Authorization: Bearer SECRETO\" https://x"}}' \
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