@trycore/spec-build-harness 0.12.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/METODOLOGIA.md +6 -1
  3. package/README.md +1 -0
  4. package/VERSION +1 -1
  5. package/agents/build/dor-dod-gatekeeper.md +10 -3
  6. package/commands/build/epic.md +109 -0
  7. package/commands/build/onboard.md +59 -17
  8. package/commands/build/slice.md +4 -2
  9. package/commands/build/work.md +6 -2
  10. package/config/build-config.template.json +2 -1
  11. package/dist/commands/init.js +13 -0
  12. package/dist/commands/status.js +13 -1
  13. package/dist/lib/normalize.js +11 -0
  14. package/dist/lib/paths.js +1 -0
  15. package/dist/lib/runtime-client.js +20 -0
  16. package/dist/lib/state-bundle.js +15 -1
  17. package/docs/commands.md +2 -1
  18. package/docs/hooks.md +11 -1
  19. package/docs/runtime/protocolo-cliente-runtime.md +90 -0
  20. package/hooks/build/design-source-guard.sh +12 -1
  21. package/hooks/build/heartbeat.sh +64 -4
  22. package/hooks/build/lib/agent-context.sh +16 -7
  23. package/hooks/build/lib/config.sh +25 -1
  24. package/hooks/build/lib/runtime-client.sh +540 -9
  25. package/hooks/build/lib/runtime-ops.sh +108 -0
  26. package/hooks/build/scaffold-guard.sh +14 -1
  27. package/hooks/build/slice-ops.sh +498 -16
  28. package/package.json +1 -1
  29. package/scripts/lib/graph-bundle.py +165 -1
  30. package/scripts/tests/test-config.sh +41 -0
  31. package/scripts/tests/test-hooks-runtime.sh +198 -3
  32. package/scripts/tests/test-install.sh +23 -0
  33. package/scripts/tests/test-runtime-client.sh +500 -0
  34. package/scripts/tests/test-skill-ops.sh +574 -0
  35. package/skills/building-a-slice/references/dor.md +21 -9
  36. package/skills/building-a-slice/references/foundation-contract.md +4 -0
  37. package/state/README.md +12 -0
@@ -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
  }
@@ -22,4 +22,45 @@ got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" config_get runtime.mode legacy)"
22
22
  got="$(bash -c 'source "$1/hooks/build/lib/state-io.sh" 2>/dev/null && BUILD_CONFIG_FILE="$2" config_get context.warning_pct 35' _ "$ROOT" "$TMP/cfg.json" 2>&1)"
23
23
  [ "$got" = 40 ] && echo "OK state-io re-exporta config_get" || { echo "FAIL state-io no re-exporta ($got)"; fail=1; }
24
24
 
25
+ # ── config_root en worktrees ──────────────────────────────────────────────────
26
+ # El estado del arnés está en .gitignore (secretos + working state), así que NO
27
+ # viaja a un `git worktree add`. Resolviendo la raíz solo por --show-toplevel, el
28
+ # agente que arranca en un worktree se queda sin credenciales y opera huérfano:
29
+ # sin identidad, sin lease y con los guards inhibidos. Nadie lo probaba, y por eso
30
+ # sobrevivió hasta 0.13.0.
31
+ # `git rev-parse --git-common-dir` devuelve la ruta ya resuelta, y en macOS /var es
32
+ # un symlink a /private/var: se compara contra la ruta FÍSICA (pwd -P) para no
33
+ # medir la forma en que se escribió el path.
34
+ MAIN="$(mkdir -p "$TMP/main" && cd "$TMP/main" && pwd -P)"
35
+ mkdir -p "$MAIN/.claude/state"
36
+ git -C "$MAIN" init -q 2>/dev/null
37
+ git -C "$MAIN" config user.email t@t.io; git -C "$MAIN" config user.name t
38
+ echo x > "$MAIN/f"; git -C "$MAIN" add -A >/dev/null 2>&1; git -C "$MAIN" commit -qm init >/dev/null 2>&1
39
+ echo '{"runtime_url":"http://localhost"}' > "$MAIN/.claude/state/runtime.credentials"
40
+ WT="$TMP/wt" # lo crea `git worktree add`, se normaliza al usarlo
41
+ git -C "$MAIN" worktree add -q "$WT" -b wt-test >/dev/null 2>&1
42
+
43
+ got="$(cd "$WT" && unset CLAUDE_PROJECT_DIR; config_root)"
44
+ [ "$got" = "$MAIN" ] && echo "OK config_root cae al clon principal desde un worktree" \
45
+ || { echo "FAIL config_root en worktree ($got, esperado $MAIN)"; fail=1; }
46
+
47
+ # Con CLAUDE_PROJECT_DIR apuntando al worktree (que es lo que hace el harness de
48
+ # Claude Code al abrir una sesión ahí) el resultado debe ser el mismo: la variable
49
+ # dice dónde trabaja el agente, no dónde vive el estado del arnés.
50
+ got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_root)"
51
+ [ "$got" = "$MAIN" ] && echo "OK config_root ignora CLAUDE_PROJECT_DIR sin estado" \
52
+ || { echo "FAIL config_root con CLAUDE_PROJECT_DIR ($got, esperado $MAIN)"; fail=1; }
53
+
54
+ # Idempotencia: en un clon normal con estado propio, nada cambia.
55
+ got="$(cd "$MAIN" && CLAUDE_PROJECT_DIR="$MAIN" config_root)"
56
+ [ "$got" = "$MAIN" ] && echo "OK config_root respeta el clon con estado propio" \
57
+ || { echo "FAIL config_root en clon normal ($got)"; fail=1; }
58
+
59
+ # Y sin estado en ninguna parte (proyecto que aún no usa el arnés) se devuelve el
60
+ # candidato tal cual: no se inventa una raíz ajena.
61
+ BARE="$TMP/bare"; mkdir -p "$BARE"
62
+ got="$(cd "$BARE" && CLAUDE_PROJECT_DIR="$BARE" config_root)"
63
+ [ "$got" = "$BARE" ] && echo "OK config_root sin estado devuelve el candidato" \
64
+ || { echo "FAIL config_root sin estado ($got)"; fail=1; }
65
+
25
66
  exit $fail
@@ -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.