@trycore/spec-build-harness 0.13.0 → 0.14.1

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/INSTALL.md +56 -3
  3. package/METODOLOGIA.md +6 -1
  4. package/README.md +46 -2
  5. package/VERSION +1 -1
  6. package/agents/build/dor-dod-gatekeeper.md +10 -3
  7. package/commands/build/epic.md +109 -0
  8. package/commands/build/onboard.md +34 -9
  9. package/commands/build/slice.md +4 -2
  10. package/commands/build/work.md +6 -2
  11. package/config/build-config.template.json +2 -1
  12. package/dist/commands/init.js +13 -0
  13. package/dist/commands/status.js +13 -1
  14. package/dist/lib/paths.js +1 -0
  15. package/dist/lib/runtime-client.js +20 -0
  16. package/docs/commands.md +3 -2
  17. package/docs/getting-started.md +11 -1
  18. package/docs/hooks.md +11 -1
  19. package/docs/runtime/guia-modo-dual-y-migracion.md +89 -9
  20. package/docs/runtime/protocolo-cliente-runtime.md +64 -0
  21. package/hooks/build/design-source-guard.sh +12 -1
  22. package/hooks/build/heartbeat.sh +64 -4
  23. package/hooks/build/lib/agent-context.sh +16 -7
  24. package/hooks/build/lib/config.sh +25 -1
  25. package/hooks/build/lib/runtime-client.sh +540 -9
  26. package/hooks/build/lib/runtime-ops.sh +108 -0
  27. package/hooks/build/scaffold-guard.sh +14 -1
  28. package/hooks/build/slice-ops.sh +498 -16
  29. package/package.json +1 -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 +456 -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
@@ -55,16 +55,47 @@ Si `--runtime-mode` no se especifica pero sí `--runtime-url`/`--runtime-token`,
55
55
  `dual` (nunca `runtime` — el corte directo a `runtime` sin pasar por `dual` no está soportado por
56
56
  diseño: es exactamente el "big bang" que el plan de migración evita).
57
57
 
58
- ## 4. Activar `dual` en un proyecto ya instalado (`update`)
58
+ ## 4. Activar `dual` en un proyecto ya instalado
59
59
 
60
- `trycore-build update` no acepta las flags de runtime directamente hoy vuelve a correr `init` con
61
- las mismas flags sobre el proyecto existente; es idempotente (no pisa `build-state.json` ni
62
- `stack-allowlist.json`, solo añade las credenciales y el modo):
60
+ Este es el camino normal: el proyecto ya lleva el arnés en `legacy` (tiene
61
+ `.claude/.build-harness-version` y su `build-state.json` con historial) y quieres que además
62
+ espeje al hub.
63
+
64
+ **`trycore-build update` no acepta las flags de runtime.** Vuelve a correr `init` con ellas sobre
65
+ el proyecto existente: `init` detecta la instalación previa y es **idempotente** — no pisa
66
+ `build-state.json` ni `stack-allowlist.json`, solo añade las credenciales y el modo.
63
67
 
64
68
  ```bash
65
- trycore-build init --runtime-url "..." --runtime-token "..." --runtime-mode dual
69
+ # 0. Parte de un arnés al día (si vienes de una versión vieja, primero esto)
70
+ npm i -g @trycore/spec-build-harness@latest
71
+ cd /ruta/al/proyecto
72
+ trycore-build update
73
+
74
+ # 1. Añade las credenciales y activa el espejo
75
+ trycore-build init --runtime-url "https://tu-runtime.example.com" \
76
+ --runtime-token "<token-del-ADMIN>" \
77
+ --runtime-mode dual
78
+
79
+ # 2. Comprueba
80
+ trycore-build doctor
81
+ trycore-build status
66
82
  ```
67
83
 
84
+ El paso 0 importa: las credenciales y el modo se escriben igual, pero los **hooks del cliente
85
+ runtime** (`session-start`, `event-emitter`, `context-sync`, `heartbeat`, `dual-compare`,
86
+ `session-stop`) solo existen desde la 0.9.0. Si el proyecto arrastra una versión anterior, el modo
87
+ queda escrito y **nada lo ejecuta**: sin ese `update` previo el espejo no espeja nada.
88
+
89
+ > **Salto grande de versión.** `update` refresca assets y schema, nunca tu estado. Si vienes de
90
+ > varias minor por detrás, corre `trycore-build status` después y mira el informe en vez de darlo
91
+ > por bueno: te dirá si la versión instalada quedó sincronizada y si el estado sigue validando.
92
+
93
+ ### ¿Y el historial que ya tienes?
94
+
95
+ Activar `dual` **no** sube tus slices archivados: el espejo solo cubre lo que ocurra de aquí en
96
+ adelante. Para que el hub conozca el pasado, ver §7 (`trycore-build migrate`). Es opcional — el
97
+ piloto funciona igual sin ello, solo que las proyecciones del servidor empiezan vacías.
98
+
68
99
  ## 5. Verificar que quedó bien
69
100
 
70
101
  ```bash
@@ -73,19 +104,68 @@ trycore-build status # resumen + sección "Runtime": conexión, proyección,
73
104
  ```
74
105
 
75
106
  Dentro de una sesión de Claude, `/build:status` da el mismo informe (más `next-step` derivado) y
76
- `/build:claim [EP-XXX]` reclama la siguiente tarea directamente contra el runtime.
107
+ `/build:claim` reclama la siguiente tarea directamente contra el runtime. **`claim` no acepta épica
108
+ dirigida**: el hub reparte por orden de cola y `claim --epic EP-XXX` falla explícito con `rc 2`
109
+ (issue #39 — el servidor ignoraba el `epic_code` en el cuerpo y entregaba la primera épica de su
110
+ cola como si fuera la pedida, dejando un lease huérfano que solo la consola admin libera). Si
111
+ necesitas dirigir una épica concreta, termina el slice en `dual`, pide al ADMIN apagar el espejo y
112
+ reclama lo que la cola sirva.
77
113
 
78
114
  ## 6. Qué cambia en el día a día
79
115
 
80
116
  **Nada que tengas que operar tú a mano.** Las skills (`building-a-slice`, `releasing-a-version`,
81
117
  `managing-parallel-front`) conducen exactamente el mismo pipeline; por debajo, en vez de
82
- leer/escribir `build-state.json` directamente, llaman a `slice-ops.sh`/`release-ops.sh` (13
83
- subcomandos: `claim`, `gate`, `wiring`, `progress`, `checkpoint`, `submit`, `archive`, `fact`,
84
- `propose-asset`, `status`, `escalate`, y del lado release `verdict`/`front-integration`) — nunca
118
+ leer/escribir `build-state.json` directamente, llaman a `slice-ops.sh`/`release-ops.sh` — nunca
85
119
  arman peticiones a mano. En `dual`, cada transición también se **espeja** al servidor con la
86
120
  identidad del fichero (que sigue mandando). Si el espejo falla (red caída, servidor rechaza), el
87
121
  trabajo local **nunca se bloquea** — se avisa y queda como discrepancia para el comparador.
88
122
 
123
+ `slice-ops.sh` tiene **17 subcomandos** (los conducen las skills; los listamos para que puedas
124
+ leer un log o depurar, no para que los teclees):
125
+
126
+ | Subcomando | Para qué |
127
+ |---|---|
128
+ | `mode` | imprime `legacy`\|`dual`\|`runtime` |
129
+ | `claim` | reclama trabajo (el hub reparte por orden de cola; sin épica dirigida) |
130
+ | `next-step` | deriva la siguiente acción desde la caché de proyección |
131
+ | `phase` | reporta la fase del pipeline (emite la cadena que falte; idempotente) |
132
+ | `gate` | reporta un veredicto del slice |
133
+ | `submit` · `archive` | entrega y archiva el slice |
134
+ | `wiring` | siembra y actualiza el checklist de cableado |
135
+ | `progress` · `checkpoint` | bitácora y continuidad tras una caída |
136
+ | `fact` | reporta un hecho de proyecto confirmado |
137
+ | `propose-asset` | propone un documento al plano de contexto (lo publica un ADMIN) |
138
+ | `propose-epic` · `epic-status` · `epic-writeback` | proponen una épica, consultan su estado y proyectan la aprobada a `epicas.md` |
139
+ | `status` · `escalate` | informe local del agente y registro de un bloqueo |
140
+
141
+ Del lado release, `release-ops.sh` añade `verdict` y `front-integration`.
142
+
143
+ **Códigos de salida** (sobre estos ramifica la prosa de las skills): `0` ok · `2` uso · `3` legacy ·
144
+ `4` sin slice · `5` offline · `6` rechazado · `7` sin trabajo.
145
+
146
+ ### Lo que aportó la 0.14.0
147
+
148
+ - **El contexto ya no se queda congelado** (#61). El daemon de heartbeat refresca `GET /agent/context`
149
+ cada `runtime.context_refresh_s` segundos (default **45**; `0` lo desactiva; override
150
+ `TRYCORE_CONTEXT_REFRESH_S`), **solo en modo `runtime`**. Antes, la caché se hidrataba al arrancar
151
+ la sesión y en cada `claim` — y el `claim` ocurre una vez por slice, así que una terminal abierta
152
+ podía trabajar horas contra una foto vieja. Si el refresco falla, la caché anterior **se conserva**
153
+ y se marca stale (lo dice `/build:status`): quedarse sin proyección dejaría al agente huérfano.
154
+ - **Las épicas se proponen, no se escriben** (#62). En `runtime`, el agente ya no elige el `EP-XXX`:
155
+ lo propone al hub con **`/build:epic`**, un humano lo aprueba en la consola, el hub asigna la
156
+ identidad y `/build:epic --check` proyecta la épica aprobada a `docs/03-backlog/epicas.md`. El
157
+ fichero pasa a ser una **proyección del grafo**, nunca una fuente paralela. La propuesta viaja por
158
+ un carril de la cola offline, así que sobrevive a quedarte sin red.
159
+ - **Una terminal desactualizada ya no puede hacer daño** (#63). `claim` y la propuesta de épica
160
+ mandan la versión de grafo que conocen; si está rancia, el hub responde `409` y el cliente
161
+ refresca y reintenta una vez en vez de fallar. `/build:status` avisa si tu versión va por detrás.
162
+
163
+ > **Dependencia externa:** las mitades de servidor de #62 y #63 (`trycore-ia-hub#113`, `#114`, `#115`)
164
+ > **todavía no existen**. Contra un hub que no las implementa, `/build:epic` recibe un `404` y aparta
165
+ > la propuesta diciendo «esta instancia está sin soporte», sin reintentos en bucle; y un hub que no
166
+ > versiona el grafo ve exactamente el mismo cuerpo de `claim` de siempre. El refresco de contexto
167
+ > (#61) **sí funciona hoy**: no necesita nada nuevo del servidor.
168
+
89
169
  **El comparador dual** (`hooks/build/dual-compare.sh`, hook `Stop`) corre después de cada turno:
90
170
  compara la proyección del servidor contra el fichero local (épica, fase, gates ya resueltos) y, si
91
171
  divergen, lo escala vía `/build:escalate` — nunca bloquea el cierre de sesión. Es el instrumento
@@ -59,6 +59,15 @@ Reglas del cliente (las implementa `slice-ops.sh claim`, no la prosa de la skill
59
59
  (rc 2, sin abrir socket) explicando el protocolo real: terminar el slice en dual → cutover
60
60
  admin → reclamar del hub lo que la cola reparta.
61
61
 
62
+ **Versión de grafo (#63 / hub#115).** `GET /agent/context` expone `context.graph_version`; el
63
+ cliente la manda en `POST /tasks/next` y en la propuesta de épica **solo si la conoce** (un hub
64
+ que no versiona ve el cuerpo de siempre). Un `409` con `{"reason":"stale_graph","graph_version":
65
+ M}` no es un error: se anota el desfase en `.claude/state/graph-status.json`, se refresca la
66
+ proyección y se reintenta **una vez**; un segundo rechazo sale con un mensaje que nombra el
67
+ desfase y la acción. En el carril directo la versión se estampa **al despachar**, nunca al
68
+ encolar — sellarla al encolar condenaría al 409 a todo despacho diferido —, y tras tres
69
+ rechazos consecutivos la petición se aparta en vez de reintentarse en bucle.
70
+
62
71
  Actos de dominio posteriores (todos por `slice-ops.sh`, ninguno a mano):
63
72
  `POST /slices/{id}/verdicts` · `POST /checkpoints` · `POST /slices/{id}/submit` · eventos
64
73
  `wiring_*`, `progress_noted`, `slice_archived`, `slice_escalated` y los hechos de
@@ -102,6 +111,32 @@ Offline: sin runtime se trabaja con el último lock; la statusline marca `⚠ st
102
111
  - Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `checkpoint_recorded`, `gate_verdict`, `slice_escalated`, `slice_submitted`, `slice_archived`, `wiring_*`, `project_fact_updated`, `handoff_recorded` ni el propio `telemetry_gap` (nombres del catálogo v2; los alias 0.10.x de los cuatro renombrados siguen protegidos porque la capa de compat los entrega). El descarte se reporta como evento `telemetry_gap` con el payload del catálogo `{dropped, window_h, reason}` (issue #44), coalescido en un único gap mientras la cola siga sobre la cota.
103
112
  - Flush forzado en `Stop`: `session-stop.sh` deja el sentinela `outbox/.flush-request`; el daemon `heartbeat.sh` lo consume de forma asíncrona (nunca en el hilo del hook). `trycore-build doctor` **reporta** el tamaño/edad de la cola pero **no** dispara un flush síncrono — sigue sin implementar.
104
113
 
114
+ **Carril directo** (`channel: "direct"`). Un fichero de la outbox puede llevar `channel:
115
+ "direct"`, `method` y `path`: entonces no entra en el lote de `POST /events`, se despacha solo a
116
+ su endpoint. Comparte directorio, formato, cota (`RUNTIME_OUTBOX_PROTECTED`) y sentinela de
117
+ flush con la cola de eventos.
118
+
119
+ **Idempotencia del carril directo — contrato asumido de hub#113.** El cuerpo de cada petición
120
+ del carril directo lleva `client_event_id` (el mismo UUID que nombra el fichero de la outbox) como
121
+ **clave de idempotencia**: el servidor debe tratar dos peticiones con el mismo `client_event_id`
122
+ como **una sola** y devolver el mismo recurso en la segunda, igual que hace `POST /events` con los
123
+ `duplicates[]`. No es opcional. Hay **dos despachadores** posibles sobre la misma cola —el daemon
124
+ de heartbeat y el proceso en primer plano de la skill (`slice-ops.sh propose-epic`)—; el cliente
125
+ los serializa con un mutex de directorio (`.claude/state/.outbox-dispatch.lock`, `mkdir` atómico,
126
+ liberación por edad a los 2 min), pero el mutex es **por repo**: dos worktrees del mismo proyecto,
127
+ o un reintento tras un corte a mitad de respuesta, siguen pudiendo entregar la misma propuesta dos
128
+ veces. Sin deduplicación server-side eso son **dos propuestas** y, al aprobarlas, **dos `EP-XXX`**
129
+ para la misma épica — justo la colisión de numeración que el carril existe para evitar. Un
130
+ `client_event_id` ya presente en el payload del productor **manda** (no se sobrescribe); el resto
131
+ del cuerpo no se toca.
132
+
133
+ Resultado por petición: `2xx` → acuse en `outbox/acks/<cid>.json`
134
+ y borrado; `404` → **instancia del hub sin soporte**, se aparta a `rejected/` diciéndolo;
135
+ `4xx` (salvo `408`/`429`) → rechazo de contrato, se aparta; `000`/`5xx`/`408`/`429` → se
136
+ conserva y se reintenta en el siguiente ciclo. No agenda backoff propio: es de volumen
137
+ bajísimo y compartir el del lote dejaría los hechos de dominio esperando detrás. La ruta la
138
+ estampa `lib/runtime-ops.sh` al encolar — `runtime-client.sh` no conoce la tabla de endpoints.
139
+
105
140
  ## 6. Declaración de tipos de asset (`asset-types.json` del paquete)
106
141
 
107
142
  El paquete del plugin declara los tipos de documento que sabe generar. En el registro, el runtime hace upsert idempotente (aditivos auto-publicados; breaking quedan propuestos para un ADMIN):
@@ -146,6 +181,7 @@ agente jamás publica contexto.**
146
181
  | `422` (evento/veredicto rechazado por transición ilegal o schema) | **No reintentar**: mostrar la razón del servidor al modelo/usuario (compuerta mecánica funcionando); registrar localmente. |
147
182
  | Timeout/red caída | Modo offline (§4/§5); jamás bloquear PreToolUse ni el trabajo local. |
148
183
  | `409` en el renew de lease (`PUT /leases/renew`) | **No existe 410**: el servidor responde siempre `409` de cuerpo único (anti-oráculo). El daemon marca `lease_lost` en `.claude/state/heartbeat-status.json`, **no se apaga** (sigue despachando la cola) y la statusline muestra `⚠ lease`. La skill detiene el trabajo, hace checkpoint local y vuelve a reclamar. |
184
+ | `409` `stale_graph` en `tasks/next` o en la propuesta | Grafo local rancio: anotar desfase, refrescar y reintentar **una vez**; segundo rechazo → `rc 6` con el desfase nombrado. En la cola: conservar y re-estampar con la versión del momento; se aparta al **tercer rechazo contra la MISMA versión local estampada** (si la versión cambió entre medias, hubo refresco de verdad y la racha vuelve a cero). El contador va con su versión (`graph_409`, `graph_409_at_version`) dentro del fichero de la outbox: contar rechazos a secas agotaría las tres vidas de la propuesta sin que hubiera ocurrido ni un refresco, porque el despacho corre en cada `Stop` mientras el refresco de contexto va a `runtime.context_refresh_s` (45 s, y `0` lo **desactiva**). |
149
185
  | Reloj local desviado | El cliente usa siempre `expires_at` del servidor, nunca calcula TTL con reloj propio. |
150
186
 
151
187
  ## 8. Seguridad del cliente
@@ -163,6 +199,13 @@ No existe evento Timer, los hooks son efímeros y un `PostToolUse` throttled no
163
199
  - **Muerte**: cuando no queda **ningún** ppid registrado vivo (dos sesiones sobre el mismo repo comparten daemon; cerrar la primera no lo mata). Nunca se cuelga de `Stop` ni de `SessionEnd`.
164
200
  - **Deberes por tick** (`TRYCORE_HEARTBEAT_TICK_S`, default 5 s): consumir `outbox/.flush-request` y despachar; cada `lease_ttl_s/3` (valor del servidor, mínimo 10 s), `PUT /leases/renew`. **Sin lease no se apaga**: los eventos de ámbito proyecto (pre-claim) necesitan despachador.
165
201
 
202
+ **Deberes por tick** (cadencias independientes): consumir el sentinela de flush y despachar la
203
+ cola; cada `lease_ttl_s/3`, renovar el lease; cada `runtime.context_refresh_s` (default 45 s),
204
+ `GET /agent/context` para refrescar la proyección local. El refresco de contexto es la
205
+ contrapartida cliente de los `nudges` del servidor: el hub ya los emite y el cliente ya los
206
+ normaliza y los pinta — lo que faltaba era que alguien leyera con regularidad. Un refresco
207
+ fallido nunca degrada la caché: se conserva la anterior y se marca `context_stale`.
208
+
166
209
  ## 10. Operaciones de skill (`slice-ops.sh` / `release-ops.sh`)
167
210
 
168
211
  Las skills son prosa: **no** arman peticiones. Cada acto de dominio pasa por un ejecutable
@@ -185,10 +228,31 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
185
228
  | `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
186
229
  | `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales; reporta además `outbox/rejected/` (conteo + tipo y razón del rechazo más reciente, issue #52) |
187
230
  | `slice-ops.sh escalate` | evento `slice_escalated {cause}` (gate y fase dentro del texto de la causa; exige slice activo en runtime) |
231
+ | `slice-ops.sh propose-epic --file B` | `POST /projects/{id}/epic-proposals` (carril directo). Propone una épica **sin `EP-XXX`**: la identidad la asigna el hub al aprobar (hub#113). `0` enviada · `5` encolada · `6` rechazada · `3` legacy/dual. Un `epic_code` en el borrador se **ignora**, no se rechaza |
232
+ | `slice-ops.sh epic-status [--id P]` | `GET /projects/{id}/epic-proposals/{P}`. Resuelve el ciclo: `QUEUED` → `PROPOSED` → `APPROVED` (con `epic_code`) / `REJECTED`. Informativo: `rc 0` siempre en runtime |
233
+ | `slice-ops.sh epic-writeback [--id P] [--file F]` | — (local). Escribe en `docs/03-backlog/epicas.md` las épicas ya `APPROVED`, con el código del hub. Mecánico e idempotente: el fichero es proyección del grafo. `--file F` permite otro fichero del backlog (partido en varios), pero **F queda acotado al subárbol `docs/03-backlog/` del proyecto** —`realpath` sobre ambos lados, así que ni symlinks ni `..` escapan— porque los contactos de escritura del arnés en `docs/` son cuatro y acotados (METODOLOGIA §9.2) y este comando promete tocar solo el backlog. Fuera de ahí, `2` con la ruta permitida en el mensaje y **cero escritura**, exista el fichero o no. `0` escribió · `7` nada pendiente · `4` sin fichero · `2` `--file` fuera del carve-out |
188
234
  | `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts`; **sin fallback offline** (rc 5 y reintento al reconectar: el agregado `release` no entra por `POST /events`, issue #44) |
189
235
  | `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
190
236
  | `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration`; **sin fallback offline** (rc 5: `front_integration_reported` no existe en el catálogo v2, issue #44) |
191
237
 
238
+ **Forma del borrador de `propose-epic --file`:**
239
+
240
+ ```json
241
+ {
242
+ "title": "…",
243
+ "objective": "…",
244
+ "layer": "foundational|business|technical",
245
+ "files_scope": ["…"],
246
+ "depends_on": ["EP-012"],
247
+ "stories": [{"title": "…", "acceptance_criteria": "…"}]
248
+ }
249
+ ```
250
+
251
+ `title` y `objective` son obligatorios; `layer` por defecto `business` (rc 2 si no es una de las
252
+ tres). Un `epic_code`/`code`/`id` en el borrador **se ignora con aviso**, no se rechaza la
253
+ propuesta — la identidad la asigna el hub al aprobar (hub#113). El normalizador estampa
254
+ `origin: "harness-draft"` antes de encolar.
255
+
192
256
  **Códigos de salida** (contrato con la prosa): `0` ok · `2` uso · `3` modo legacy (o claim en dual)
193
257
  · `4` sin slice activo · `5` offline (encolado si el tipo tiene camino por la cola; si no, reintento manual al reconectar) · `6` rechazado por el servidor (no reintentar) ·
194
258
  `7` sin trabajo.
@@ -18,7 +18,18 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
18
  source "$HERE/lib/projection.sh"
19
19
  if [ "$(runtime_mode)" = "runtime" ]; then
20
20
  CACHE="$(runtime_projection_path)"
21
- [ -f "$CACHE" ] || exit 0
21
+ if [ ! -f "$CACHE" ]; then
22
+ # auto-arme (sin credenciales) vs desconectado (con credenciales): ver
23
+ # runtime_disconnected en lib/runtime-client.sh.
24
+ if runtime_disconnected; then
25
+ echo "⛔ sin conexión con el hub: hay credenciales del proyecto pero no hay proyección local." >&2
26
+ echo " Este agente no sabe qué trabajo tiene asignado, así que no puede escribir código." >&2
27
+ echo " Recupérala con \`slice-ops.sh status\` (refresca /agent/context) y reclama con \`/build:claim\`." >&2
28
+ echo " Si esto es un worktree: el estado del arnés vive en el clon principal y no viaja al worktree." >&2
29
+ exit 2
30
+ fi
31
+ exit 0
32
+ fi
22
33
  if ! command -v python3 >/dev/null 2>&1; then
23
34
  echo "⛔ design-source-guard: python3 no disponible; no puedo verificar el gate de fuente de diseño. Instala python3 (trycore-build doctor)." >&2
24
35
  exit 2
@@ -18,10 +18,19 @@ set -uo pipefail
18
18
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
19
  source "$HERE/lib/runtime-client.sh"
20
20
  source "$HERE/lib/projection.sh"
21
+ source "$HERE/lib/agent-context.sh"
21
22
 
22
23
  HB_TICK_S="${TRYCORE_HEARTBEAT_TICK_S:-5}"
23
24
  case "$HB_TICK_S" in ''|*[!0-9]*) HB_TICK_S=5 ;; esac
24
25
 
26
+ # Cadencia del refresco de contexto [#61]. NO es el tick: renovar un lease es barato, pero
27
+ # `GET /agent/context` es una proyección entera del proyecto — a 5 s castigaría al hub sin
28
+ # ganar nada. 45 s es del orden de la latencia con la que un humano aprueba una épica.
29
+ # `0` lo desactiva (vuelta al comportamiento previo a #61).
30
+ HB_CONTEXT_S="${TRYCORE_CONTEXT_REFRESH_S:-}"
31
+ [ -n "$HB_CONTEXT_S" ] || HB_CONTEXT_S="$(config_get runtime.context_refresh_s 45)"
32
+ case "$HB_CONTEXT_S" in ''|*[!0-9]*) HB_CONTEXT_S=45 ;; esac
33
+
25
34
  hb_pidfile() { echo "$(config_root)/.claude/state/heartbeat.pid"; }
26
35
  hb_sessions() { echo "$(config_root)/.claude/state/heartbeat-sessions.json"; }
27
36
  hb_statusfile() { echo "$(config_root)/.claude/state/heartbeat-status.json"; }
@@ -115,7 +124,11 @@ PY
115
124
  echo "$n"
116
125
  }
117
126
 
118
- # hb_write_status <json> — estado observable del daemon (lo lee statusline-bridge.sh).
127
+ # hb_write_status <fragmento-json> — UPSERT del fragmento sobre el estado observable del
128
+ # daemon (lo lee statusline-bridge.sh y `slice-ops.sh status`). Es merge y no reemplazo
129
+ # desde #61: el fichero lo escriben DOS deberes distintos del tick — la renovación del lease
130
+ # y el refresco de contexto — y con reemplazo el último en escribir borraba el diagnóstico
131
+ # del otro (un refresco correcto tapaba un `lease_lost: true` recién detectado).
119
132
  hb_write_status() {
120
133
  local body f
121
134
  body="$1"
@@ -126,14 +139,22 @@ hb_write_status() {
126
139
  import json,sys,os,tempfile
127
140
  path,body=sys.argv[1],sys.argv[2]
128
141
  try:
129
- d=json.loads(body)
142
+ d=json.load(open(path))
130
143
  except Exception:
131
144
  d={}
145
+ if not isinstance(d,dict):
146
+ d={}
147
+ try:
148
+ frag=json.loads(body)
149
+ except Exception:
150
+ frag={}
151
+ if isinstance(frag,dict):
152
+ d.update(frag)
132
153
  dirn=os.path.dirname(path) or "."
133
154
  fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".heartbeat-status.",suffix=".tmp")
134
155
  try:
135
156
  with os.fdopen(fd,"w") as out:
136
- json.dump(d,out,indent=2)
157
+ json.dump(d,out,indent=2); out.flush(); os.fsync(out.fileno())
137
158
  os.replace(tmp,path)
138
159
  except Exception:
139
160
  try: os.unlink(tmp)
@@ -168,8 +189,40 @@ hb_renew() {
168
189
  esac
169
190
  }
170
191
 
192
+ # hb_refresh_context — refresca la caché de proyección desde GET /agent/context. Es el ÚNICO
193
+ # camino por el que una terminal ABIERTA se entera de algo nuevo: `session-start.sh` hidrata
194
+ # una vez al arrancar y `slice-ops.sh` solo en claim/status — y el claim ocurre una vez por
195
+ # slice, así que sin esto una sesión larga trabaja horas contra una foto vieja (#61).
196
+ # Solo en modo `runtime`: en legacy|dual el fichero local es primario y el comportamiento no
197
+ # cambia. `agent_context_fetch_and_cache` NUNCA pisa la caché anterior si falla (fail-open);
198
+ # aquí solo se anota el diagnóstico, para que `status` distinga «vieja» de «no se pudo».
199
+ #
200
+ # `last_context_status` es SIEMPRE una cadena. El diagnóstico no es solo un código HTTP
201
+ # (`"write_error"` no lo es) y `"000"` —el valor de «sin red» de `runtime_http_status`— no es
202
+ # un número JSON válido: emitirlo sin comillas rompía el parseo del fragmento entero y
203
+ # `hb_write_status` acababa no escribiendo NADA, ni siquiera el `context_stale: true`.
204
+ hb_refresh_context() {
205
+ local status rc
206
+ [ "$(runtime_mode)" = "runtime" ] || return 0
207
+ agent_context_fetch_and_cache >/dev/null 2>&1
208
+ rc=$?
209
+ if [ "$rc" -eq 0 ]; then
210
+ hb_write_status "{\"context_stale\": false, \"last_context_status\": \"200\", \"last_context_refresh_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
211
+ return 0
212
+ fi
213
+ # [#61 · ronda final] Un 200 que no se pudo normalizar o escribir NO es un fallo de red:
214
+ # anotarlo como «último intento: 200» mandaba a mirar el sitio equivocado.
215
+ if [ "$rc" -eq 2 ]; then
216
+ status="write_error"
217
+ else
218
+ status="$(runtime_http_status)"
219
+ fi
220
+ hb_write_status "{\"context_stale\": true, \"last_context_status\": \"$status\"}"
221
+ return 1
222
+ }
223
+
171
224
  hb_daemon() {
172
- local pidf interval ttl last now n
225
+ local pidf interval ttl last last_ctx now n
173
226
  pidf="$(hb_pidfile)"
174
227
  mkdir -p "$(dirname "$pidf")"
175
228
  printf '%s' "$$" > "$pidf"
@@ -184,6 +237,9 @@ hb_daemon() {
184
237
  ;;
185
238
  esac
186
239
  last=0
240
+ # `session-start.sh` acaba de hidratar la caché: arrancar el contador AHORA evita un
241
+ # refresco redundante en el primer tick del daemon.
242
+ last_ctx="$(date +%s)"
187
243
  while :; do
188
244
  sleep "$HB_TICK_S"
189
245
  n="$(hb_prune_sessions)"
@@ -194,6 +250,10 @@ hb_daemon() {
194
250
  runtime_dispatch_outbox >/dev/null 2>&1
195
251
  fi
196
252
  now="$(date +%s)"
253
+ if [ "$HB_CONTEXT_S" -gt 0 ] && [ $(( now - last_ctx )) -ge "$HB_CONTEXT_S" ]; then
254
+ hb_refresh_context
255
+ last_ctx="$now"
256
+ fi
197
257
  if [ $(( now - last )) -ge "$interval" ]; then
198
258
  hb_renew
199
259
  runtime_dispatch_outbox >/dev/null 2>&1
@@ -102,8 +102,12 @@ out = {
102
102
  "checkpoint": checkpoint,
103
103
  },
104
104
  "nudges": nudges,
105
+ # [#63] `graph_version` viaja junto al manifest_hash: es la foto del BACKLOG (épicas y sus
106
+ # estados), no la del contexto gobernado. Un hub que no versiona la deja en None y el
107
+ # cliente sigue funcionando exactamente igual que antes (compatibilidad hacia atrás).
105
108
  "context": {"manifest_hash": first(ctx.get("manifest_hash"), raw.get("manifest_hash")),
106
- "version": first(ctx.get("version"), raw.get("context_version"))},
109
+ "version": first(ctx.get("version"), raw.get("context_version")),
110
+ "graph_version": first(ctx.get("graph_version"), raw.get("graph_version"))},
107
111
  "lease": {"expires_at": first(lease.get("expires_at"), raw.get("lease_expires_at")),
108
112
  "ttl_s": first(lease.get("ttl_s"), raw.get("lease_ttl_s"))},
109
113
  }
@@ -120,20 +124,25 @@ PY
120
124
  }
121
125
 
122
126
  # agent_context_fetch_and_cache — GET /agent/context, normaliza y escribe la caché.
123
- # rc 0 = caché actualizada · 1 = no se pudo (red, status != 200, respuesta ilegible):
124
- # en ese caso la caché anterior NO se toca, y los guards siguen operando con ella.
127
+ # rc 0 = caché actualizada · 1 = no se pudo TRAER (red caída, status != 200) · 2 = el hub
128
+ # respondió 200 pero la respuesta no se pudo normalizar o la proyección no se pudo escribir.
129
+ # En ambos fallos la caché anterior NO se toca y los guards siguen operando con ella.
130
+ # [#61 · ronda final] El 2 no es cosmético: con un solo rc de fallo, quien diagnostica después
131
+ # leía `runtime_http_status` == 200 y anunciaba «no se pudo refrescar (último intento: 200)»,
132
+ # que manda a mirar la red cuando el problema está en el disco o en el cuerpo de la respuesta.
133
+ # Todos los llamadores ramifican sobre «rc != 0», así que el código nuevo no cambia su flujo.
125
134
  agent_context_fetch_and_cache() {
126
135
  local body status tmp norm rc
127
136
  body="$(runtime_get "$AGENT_CONTEXT_PATH")"
128
137
  status="$(runtime_http_status)"
129
138
  [ "$status" = "200" ] || return 1
130
- tmp="$(mktemp)" || return 1
139
+ tmp="$(mktemp)" || return 2
131
140
  printf '%s' "$body" > "$tmp"
132
141
  norm="$(agent_context_normalize "$tmp")"
133
142
  rc=$?
134
143
  rm -f "$tmp"
135
- [ $rc -eq 0 ] || return 1
136
- [ -n "$norm" ] && [ "$norm" != "{}" ] || return 1
137
- runtime_projection_write "$norm" || return 1
144
+ [ $rc -eq 0 ] || return 2
145
+ [ -n "$norm" ] && [ "$norm" != "{}" ] || return 2
146
+ runtime_projection_write "$norm" || return 2
138
147
  return 0
139
148
  }
@@ -5,8 +5,32 @@
5
5
  # config_get. Diseño: fail-open, igual que state-io.sh — nunca lanza.
6
6
 
7
7
  # config_root — raíz del proyecto consumidor (misma señal que state_path en state-io.sh).
8
+ #
9
+ # Worktrees: el estado del arnés (runtime.credentials, runtime-projection.json,
10
+ # outbox/, build-state.json) está en .gitignore — es secreto y working state, no
11
+ # se versiona — así que NO viaja a un `git worktree add`. Resolviendo la raíz solo
12
+ # por el toplevel, todo agente que arranque en un worktree se queda sin
13
+ # credenciales y opera HUÉRFANO: sin identidad de proyecto, sin lease, sin
14
+ # reportar al hub, y con los guards inhibidos porque su fail-open de auto-arme no
15
+ # distingue "este proyecto no usa el arnés" de "no veo el estado desde aquí".
16
+ # Por eso, cuando el árbol actual no tiene estado, se cae al clon principal
17
+ # (--git-common-dir), que es donde vive de verdad. Idempotente para un clon
18
+ # normal: si el candidato ya tiene estado, se devuelve tal cual.
8
19
  config_root() {
9
- echo "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
20
+ local candidate
21
+ candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
22
+ [ -f "$candidate/.claude/state/runtime.credentials" ] && { echo "$candidate"; return; }
23
+
24
+ local common main
25
+ common="$(git -C "$candidate" rev-parse --git-common-dir 2>/dev/null)" || common=""
26
+ if [ -n "$common" ]; then
27
+ case "$common" in /*) ;; *) common="$candidate/$common" ;; esac
28
+ main="$(cd "$common/.." 2>/dev/null && pwd)" || main=""
29
+ if [ -n "$main" ] && [ -f "$main/.claude/state/runtime.credentials" ]; then
30
+ echo "$main"; return
31
+ fi
32
+ fi
33
+ echo "$candidate"
10
34
  }
11
35
 
12
36
  # config_get <clave.punteada> <default>