@trycore/spec-build-harness 0.13.0 → 0.14.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.
@@ -126,6 +126,45 @@ got="$(projection_get active_slice.slice_id "")"
126
126
  [ "$got" = "sl-9" ] && echo "OK fetch_and_cache escribe la cache" || { echo "FAIL cache tras fetch ($got)"; fail=1; }
127
127
  if projection_is_stale; then echo "FAIL cache recien traida es stale"; fail=1; else echo "OK cache recien traida es fresca"; fi
128
128
 
129
+ # [#63] La versión de grafo se normaliza igual que el manifest_hash: un solo sitio conoce la
130
+ # forma cruda del servidor.
131
+ python3 - "$TMP/routes-ac.json" <<'PY'
132
+ import json,sys
133
+ p=sys.argv[1]; d=json.load(open(p))
134
+ r=d["GET /agent/context"]["body"]
135
+ r.setdefault("context",{})["graph_version"]=42
136
+ json.dump(d,open(p,"w"))
137
+ PY
138
+ agent_context_fetch_and_cache >/dev/null 2>&1
139
+ got="$(projection_get context.graph_version "")"
140
+ [ "$got" = 42 ] && echo "OK agent-context normaliza context.graph_version" \
141
+ || { echo "FAIL graph_version sin normalizar ($got)"; fail=1; }
142
+ [ "$(_runtime_graph_version)" = 42 ] && echo "OK _runtime_graph_version lee la version conocida" \
143
+ || { echo "FAIL _runtime_graph_version"; fail=1; }
144
+
145
+ # Hub que NO versiona: graph_version queda null y nada cambia.
146
+ python3 - "$TMP/routes-ac.json" <<'PY'
147
+ import json,sys
148
+ p=sys.argv[1]; d=json.load(open(p))
149
+ d["GET /agent/context"]["body"]["context"].pop("graph_version",None)
150
+ json.dump(d,open(p,"w"))
151
+ PY
152
+ agent_context_fetch_and_cache >/dev/null 2>&1
153
+ [ "$(projection_get context.graph_version "")" = "" ] && [ "$(_runtime_graph_version)" = "" ] \
154
+ && echo "OK sin versión en el hub, graph_version queda vacia" \
155
+ || { echo "FAIL graph_version inventada sin hub que versione"; fail=1; }
156
+
157
+ # Marcador de desfase: se anota y se limpia.
158
+ runtime_graph_note_stale 42 45
159
+ runtime_graph_status_read | python3 -c "
160
+ import json,sys
161
+ d=json.load(sys.stdin)
162
+ print('SI' if d.get('local')==42 and d.get('server')==45 and d.get('seen_at') else 'NO')" | grep -q SI \
163
+ && echo "OK runtime_graph_note_stale anota el desfase" || { echo "FAIL note_stale"; fail=1; }
164
+ runtime_graph_clear_stale
165
+ [ "$(runtime_graph_status_read)" = "{}" ] && echo "OK runtime_graph_clear_stale borra el marcador" \
166
+ || { echo "FAIL clear_stale"; fail=1; }
167
+
129
168
  kill "$STUB_AC" 2>/dev/null; wait "$STUB_AC" 2>/dev/null
130
169
  # Servidor caído: rc 1 y la caché ANTERIOR intacta (nunca se pisa con vacío).
131
170
  agent_context_fetch_and_cache
@@ -284,9 +323,142 @@ sleep 4
284
323
  kill -0 "$HBPID3" 2>/dev/null && { echo "FAIL daemon sobrevive sin sesiones"; fail=1; } || echo "OK daemon muere con la ultima sesion"
285
324
  [ ! -f "$PROJ/.claude/state/heartbeat.pid" ] && echo "OK daemon limpia el pidfile" || { echo "FAIL pidfile huerfano"; fail=1; }
286
325
 
326
+ # [#61] Con el daemon vivo, un cambio en /agent/context llega a la proyección local SIN
327
+ # reiniciar la sesión ni hacer claim: hoy la caché solo se hidrataba en session-start y claim.
328
+ export TRYCORE_CONTEXT_REFRESH_S=1
329
+ cat > "$TMP/routes-hb.json" <<JSON
330
+ {"PUT /leases/renew": {"status": 200, "body": {"expires_at": "2099-01-01T00:00:00Z"}},
331
+ "GET /agent/context": {"status": 200, "body": {"project": {"scaffold": {"confirmed": true}}, "active_slice": {"slice_id": "sl-9", "epic_code": "EP-077", "phase": "green"}, "nudges": [{"kind": "info", "message": "épica EP-077 aprobada"}], "context": {"manifest_hash": "sha256:v2", "version": 9}}},
332
+ "POST /events": {"status": 200, "body": {"results": []}}}
333
+ JSON
334
+ sleep 60 & SESSC=$!
335
+ bash "$ROOT/hooks/build/heartbeat.sh" --ensure "$SESSC" >/dev/null 2>&1
336
+ sleep 4
337
+ got="$(python3 -c "
338
+ import json
339
+ d=json.load(open('$PROJ/.claude/state/runtime-projection.json'))
340
+ print((d.get('active_slice') or {}).get('epic_code'))" 2>/dev/null)"
341
+ [ "$got" = "EP-077" ] && echo "OK el daemon refresca la proyeccion sin claim ni reinicio" \
342
+ || { echo "FAIL la proyeccion no se refresco ($got)"; fail=1; }
343
+ got="$(python3 -c "
344
+ import json
345
+ d=json.load(open('$PROJ/.claude/state/runtime-projection.json'))
346
+ print((d.get('nudges') or [{}])[0].get('message'))" 2>/dev/null)"
347
+ [ "$got" = "épica EP-077 aprobada" ] && echo "OK el nudge del hub llega a la proyeccion local" \
348
+ || { echo "FAIL el nudge no llego ($got)"; fail=1; }
349
+ # [Ronda final] `last_context_status` tiene UN tipo: cadena. El éxito lo escribía como número
350
+ # y el fallo como cadena — el mismo campo con dos tipos según cómo fue, y quien lo lee no
351
+ # puede compararlo sin adivinar.
352
+ tipo="$(python3 -c "
353
+ import json
354
+ print(type(json.load(open('$PROJ/.claude/state/heartbeat-status.json')).get('last_context_status')).__name__)" 2>/dev/null)"
355
+ [ "$tipo" = str ] && echo "OK last_context_status es siempre una cadena (tambien en el exito)" \
356
+ || { echo "FAIL last_context_status cambia de tipo segun el resultado ($tipo)"; fail=1; }
357
+
358
+ # Fallo de red en el refresco: la caché ANTERIOR queda intacta y marcada stale. Nunca dejar
359
+ # al agente sin proyección: eso lo convierte en huérfano (regresión del PR #60).
360
+ cat > "$TMP/routes-hb.json" <<JSON
361
+ {"PUT /leases/renew": {"status": 200, "body": {"expires_at": "2099-01-01T00:00:00Z"}},
362
+ "GET /agent/context": {"status": 500, "body": {"error": "boom"}},
363
+ "POST /events": {"status": 200, "body": {"results": []}}}
364
+ JSON
365
+ sleep 4
366
+ got="$(python3 -c "
367
+ import json
368
+ d=json.load(open('$PROJ/.claude/state/runtime-projection.json'))
369
+ print((d.get('active_slice') or {}).get('epic_code'))" 2>/dev/null)"
370
+ [ "$got" = "EP-077" ] && echo "OK un refresco fallido conserva la cache anterior" \
371
+ || { echo "FAIL la cache se perdio con el fallo de red ($got)"; fail=1; }
372
+ stale="$(python3 -c "
373
+ import json
374
+ print(json.load(open('$PROJ/.claude/state/heartbeat-status.json')).get('context_stale'))" 2>/dev/null)"
375
+ [ "$stale" = True ] && echo "OK un refresco fallido marca la proyeccion stale" \
376
+ || { echo "FAIL refresco fallido sin marcar stale ($stale)"; fail=1; }
377
+
378
+ # Los guards nunca leen una caché a medio escribir: mientras el daemon refresca en bucle,
379
+ # un lector concurrente parsea el fichero 300 veces sin un solo JSON inválido.
380
+ cat > "$TMP/routes-hb.json" <<JSON
381
+ {"PUT /leases/renew": {"status": 200, "body": {"expires_at": "2099-01-01T00:00:00Z"}},
382
+ "GET /agent/context": {"status": 200, "body": {"project": {}, "active_slice": {"slice_id": "sl-9", "epic_code": "EP-077", "phase": "green"}, "nudges": [], "context": {"manifest_hash": "sha256:v2", "version": 9}}},
383
+ "POST /events": {"status": 200, "body": {"results": []}}}
384
+ JSON
385
+ bad="$(python3 - "$PROJ/.claude/state/runtime-projection.json" <<'PY'
386
+ import json,sys,time
387
+ path=sys.argv[1]; bad=0
388
+ for _ in range(300):
389
+ try:
390
+ json.load(open(path))
391
+ except Exception:
392
+ bad+=1
393
+ time.sleep(0.01)
394
+ print(bad)
395
+ PY
396
+ )"
397
+ [ "$bad" = 0 ] && echo "OK el lector concurrente nunca ve la cache a medio escribir" \
398
+ || { echo "FAIL $bad lecturas parciales de la proyeccion"; fail=1; }
399
+
400
+ # [#61 · ronda final] El refresco tiene DOS fallos distintos y no se pueden anunciar igual:
401
+ # «el hub no respondió» y «respondió 200 pero no pudimos escribir la proyección». Con un solo
402
+ # código, `slice-ops.sh status` imprimía «no pudo refrescar (último intento: 200)» y mandaba a
403
+ # mirar la red teniendo el problema en el disco. Fixture: la proyección es un DIRECTORIO, así
404
+ # que el `os.replace` final falla con el 200 ya en la mano.
405
+ rm -f "$PROJ/.claude/state/runtime-projection.json"
406
+ mkdir -p "$PROJ/.claude/state/runtime-projection.json"
407
+ sleep 4
408
+ ctxst="$(python3 -c "
409
+ import json
410
+ print(json.load(open('$PROJ/.claude/state/heartbeat-status.json')).get('last_context_status'))" 2>/dev/null)"
411
+ [ "$ctxst" = "write_error" ] && echo "OK un 200 que no se pudo escribir no se reporta como 200" \
412
+ || { echo "FAIL el fallo de escritura se anuncia como estado HTTP ($ctxst)"; fail=1; }
413
+ rmdir "$PROJ/.claude/state/runtime-projection.json" 2>/dev/null
414
+ sleep 3
415
+
416
+ # Sin red, `runtime_http_status` vale `000` — que NO es un número JSON válido. Emitido sin
417
+ # comillas rompía el fragmento entero y `hb_write_status` no escribía NADA: ni el estado ni el
418
+ # `context_stale: true`, o sea el fallo desaparecía en silencio. El campo es SIEMPRE cadena.
419
+ python3 -c "
420
+ import json
421
+ d=json.load(open('$PROJ/.claude/state/heartbeat-status.json'))
422
+ d['context_stale']=False
423
+ json.dump(d,open('$PROJ/.claude/state/heartbeat-status.json','w'))"
424
+ kill "$STUB_HB" 2>/dev/null; wait "$STUB_HB" 2>/dev/null
425
+ sleep 4
426
+ ctx000="$(python3 -c "
427
+ import json
428
+ d=json.load(open('$PROJ/.claude/state/heartbeat-status.json'))
429
+ print(repr(d.get('last_context_status')), d.get('context_stale'))" 2>/dev/null)"
430
+ [ "$ctx000" = "'000' True" ] && echo "OK sin red el estado del refresco se registra como cadena \"000\"" \
431
+ || { echo "FAIL el fragmento con 000 no se registro ($ctx000)"; fail=1; }
432
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_HB" "$TMP/routes-hb.json" "$TMP/calls-hb.jsonl" &
433
+ STUB_HB=$!
434
+ sleep 0.5
435
+
436
+ # legacy|dual: el daemon NO refresca (AC de #61).
437
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
438
+ python3 -c "
439
+ import json
440
+ json.dump({'fetched_at':'2020-01-01T00:00:00Z','active_slice':{'slice_id':'sl-9','epic_code':'EP-VIEJA'},'nudges':[]},
441
+ open('$PROJ/.claude/state/runtime-projection.json','w'))"
442
+ : > "$TMP/calls-hb.jsonl"
443
+ TRYCORE_RUNTIME_MODE=dual bash "$ROOT/hooks/build/heartbeat.sh" --ensure "$SESSC" >/dev/null 2>&1
444
+ sleep 4
445
+ got="$(python3 -c "
446
+ import json
447
+ d=json.load(open('$PROJ/.claude/state/runtime-projection.json'))
448
+ print((d.get('active_slice') or {}).get('epic_code'))" 2>/dev/null)"
449
+ [ "$got" = "EP-VIEJA" ] && echo "OK en dual el daemon no refresca el contexto" \
450
+ || { echo "FAIL el daemon refresco en dual ($got)"; fail=1; }
451
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
452
+ kill "$SESSC" 2>/dev/null; wait "$SESSC" 2>/dev/null
453
+ unset TRYCORE_CONTEXT_REFRESH_S
454
+
287
455
  # 409 en el renew: se marca lease perdido y NO se apaga el daemon (spec §4.3).
456
+ # [#61] heartbeat-status.json es un MERGE, no un reemplazo: dos deberes del tick (lease y
457
+ # contexto) escriben el mismo fichero y ninguno puede borrar el diagnóstico del otro.
458
+ export TRYCORE_CONTEXT_REFRESH_S=1
288
459
  cat > "$TMP/routes-hb.json" <<JSON
289
460
  {"PUT /leases/renew": {"status": 409, "body": {"error": "lease_conflict"}},
461
+ "GET /agent/context": {"status": 200, "body": {"project": {"scaffold": {"confirmed": true}}, "active_slice": {"slice_id": "sl-9", "phase": "green"}, "nudges": [], "context": {"manifest_hash": "sha256:v2", "version": 9}}},
290
462
  "POST /events": {"status": 200, "body": {"results": []}}}
291
463
  JSON
292
464
  sleep 60 & SESS3=$!
@@ -297,12 +469,22 @@ lost="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/heartb
297
469
  HBPID4="$(cat "$PROJ/.claude/state/heartbeat.pid" 2>/dev/null)"
298
470
  kill -0 "$HBPID4" 2>/dev/null && echo "OK daemon sigue vivo tras 409" || { echo "FAIL daemon murio por 409"; fail=1; }
299
471
 
472
+ # El daemon lleva ya un 409 anotado (lease_lost: true). Un refresco de contexto posterior
473
+ # NO puede borrarlo.
474
+ sleep 4
475
+ merged="$(python3 -c "
476
+ import json
477
+ d=json.load(open('$PROJ/.claude/state/heartbeat-status.json'))
478
+ print('SI' if d.get('lease_lost') is True and 'context_stale' in d else 'NO')" 2>/dev/null)"
479
+ [ "$merged" = SI ] && echo "OK heartbeat-status conserva lease_lost tras el refresco de contexto" \
480
+ || { echo "FAIL heartbeat-status se reemplaza en vez de mezclarse ($merged)"; fail=1; }
481
+
300
482
  bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
301
483
  sleep 1
302
484
  kill -0 "$HBPID4" 2>/dev/null && { echo "FAIL --stop no apago el daemon"; fail=1; } || echo "OK --stop apaga el daemon"
303
485
  kill "$SESS3" 2>/dev/null; wait "$SESS3" 2>/dev/null
304
486
  kill "$STUB_HB" 2>/dev/null; wait "$STUB_HB" 2>/dev/null
305
- unset TRYCORE_HEARTBEAT_TICK_S TRYCORE_HEARTBEAT_INTERVAL_S
487
+ unset TRYCORE_HEARTBEAT_TICK_S TRYCORE_HEARTBEAT_INTERVAL_S TRYCORE_CONTEXT_REFRESH_S
306
488
 
307
489
  # --- session-start.sh ---
308
490
  PORT_SS="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
@@ -458,10 +640,23 @@ rm -f "$PROJ/.claude/state/build-state.json"
458
640
  # --- guards desde la caché de proyección (modo runtime) ---
459
641
  HOOK_IN='{"tool_name":"Write","tool_input":{"file_path":"'"$PROJ"'/src/x.ts","content":"x"}}'
460
642
 
461
- # Sin caché: auto-arme, no bloquea (nada que vigilar todavía).
643
+ # Sin caché hay DOS escenarios opuestos y el guard debe distinguirlos. Hasta 0.13.0
644
+ # los dos caían en el mismo exit 0, y por eso un agente desconectado del hub (todo
645
+ # worktree lo está: el estado no viaja) podía escribir código sin que nada lo
646
+ # parase, que es como dos workers acaban pisándose.
462
647
  rm -f "$PROJ/.claude/state/runtime-projection.json"
648
+
649
+ # (a) Con credenciales = proyecto registrado que NO ve al hub → bloquea.
650
+ echo "$HOOK_IN" | bash "$ROOT/hooks/build/scaffold-guard.sh" >/dev/null 2>&1
651
+ [ $? -eq 2 ] && echo "OK scaffold-guard bloquea si está desconectado del hub" \
652
+ || { echo "FAIL scaffold-guard no bloquea desconectado"; fail=1; }
653
+
654
+ # (b) Sin credenciales = el proyecto aún no usa el arnés → auto-arme, deja pasar.
655
+ mv "$PROJ/.claude/state/runtime.credentials" "$PROJ/.claude/state/creds.bak"
463
656
  echo "$HOOK_IN" | bash "$ROOT/hooks/build/scaffold-guard.sh" >/dev/null 2>&1
464
- [ $? -eq 0 ] && echo "OK scaffold-guard sin cache no bloquea" || { echo "FAIL scaffold-guard bloqueo sin cache"; fail=1; }
657
+ [ $? -eq 0 ] && echo "OK scaffold-guard respeta el auto-arme sin credenciales" \
658
+ || { echo "FAIL scaffold-guard rompe el auto-arme"; fail=1; }
659
+ mv "$PROJ/.claude/state/creds.bak" "$PROJ/.claude/state/runtime.credentials"
465
660
 
466
661
  # Fase de código + scaffold NO confirmado -> bloquea.
467
662
  runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":false,\"design_source_applies\":true,\"design_source_confirmed\":false},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{\"fidelity\":false},\"wiring_failing\":[]},\"nudges\":[]}"
@@ -173,6 +173,29 @@ else
173
173
  echo "FAIL install: .gitignore no protege los artefactos del runtime"
174
174
  fail=1
175
175
  fi
176
+ # [Ronda de arreglo 1, Minor 7 / #63] El ledger de propuestas y el marcador de desfase de
177
+ # grafo son estado derivado del hub, igual que los de arriba: mismo blindaje.
178
+ if [ -f "$GI" ] \
179
+ && grep -q '^\.claude/state/epic-proposals\.json$' "$GI" \
180
+ && grep -q '^\.claude/state/graph-status\.json$' "$GI"; then
181
+ echo "OK install: .gitignore protege epic-proposals/graph-status"
182
+ else
183
+ echo "FAIL install: .gitignore no protege epic-proposals.json/graph-status.json"
184
+ fail=1
185
+ fi
186
+ # [Ronda final · C5] Los tres ficheros del daemon de heartbeat (EP-OR-08-B) son estado LOCAL
187
+ # de la máquina —un pid y unos ppids no significan nada en otro clon— y llevaban desde
188
+ # entonces fuera de la semilla: un consumidor los commiteaba y el siguiente `git pull` traía
189
+ # un pidfile ajeno que el relanzamiento del daemon toma por bueno hasta el `kill -0`.
190
+ if [ -f "$GI" ] \
191
+ && grep -q '^\.claude/state/heartbeat\.pid$' "$GI" \
192
+ && grep -q '^\.claude/state/heartbeat-sessions\.json$' "$GI" \
193
+ && grep -q '^\.claude/state/heartbeat-status\.json$' "$GI"; then
194
+ echo "OK install: .gitignore protege los tres ficheros del heartbeat"
195
+ else
196
+ echo "FAIL install: .gitignore no protege heartbeat.pid/heartbeat-sessions.json/heartbeat-status.json"
197
+ fail=1
198
+ fi
176
199
  rm -rf "$TMP_BF"
177
200
 
178
201
  # 6) Ambiguo: manifiesto + código pero SIN repo git (sin historial) → project_kind null.