@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.
- package/.claude-plugin/plugin.json +1 -1
- package/METODOLOGIA.md +6 -1
- package/README.md +1 -0
- package/VERSION +1 -1
- package/agents/build/dor-dod-gatekeeper.md +10 -3
- package/commands/build/epic.md +109 -0
- package/commands/build/onboard.md +59 -17
- package/commands/build/slice.md +4 -2
- package/commands/build/work.md +6 -2
- package/config/build-config.template.json +2 -1
- package/dist/commands/init.js +13 -0
- package/dist/commands/status.js +13 -1
- package/dist/lib/normalize.js +11 -0
- package/dist/lib/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/dist/lib/state-bundle.js +15 -1
- package/docs/commands.md +2 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/protocolo-cliente-runtime.md +90 -0
- package/hooks/build/design-source-guard.sh +12 -1
- package/hooks/build/heartbeat.sh +64 -4
- package/hooks/build/lib/agent-context.sh +16 -7
- package/hooks/build/lib/config.sh +25 -1
- package/hooks/build/lib/runtime-client.sh +540 -9
- package/hooks/build/lib/runtime-ops.sh +108 -0
- package/hooks/build/scaffold-guard.sh +14 -1
- package/hooks/build/slice-ops.sh +498 -16
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +165 -1
- package/scripts/tests/test-config.sh +41 -0
- package/scripts/tests/test-hooks-runtime.sh +198 -3
- package/scripts/tests/test-install.sh +23 -0
- package/scripts/tests/test-runtime-client.sh +500 -0
- package/scripts/tests/test-skill-ops.sh +574 -0
- package/skills/building-a-slice/references/dor.md +21 -9
- package/skills/building-a-slice/references/foundation-contract.md +4 -0
- 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
|
|
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
|
|
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.
|