@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
|
@@ -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.
|
|
@@ -201,3 +265,29 @@ reducer **no bloquea** el trabajo local: se avisa y queda como discrepancia del
|
|
|
201
265
|
**Lo que el agente NO hace** (superficies humanas, con PDP): cerrar una release, planificar/abrir/
|
|
202
266
|
drenar/cerrar un front, subir el bundle de import del grafo y publicar contexto. El arnés prepara,
|
|
203
267
|
propone y reporta; la persona decide en la consola del hub.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 11. Bundle de import histórico (`POST /projects/{id}/import/bundle`)
|
|
272
|
+
|
|
273
|
+
Lo produce `trycore-build migrate` (`src/lib/state-bundle.ts`) y lo sube una persona con sesión de
|
|
274
|
+
ADMIN. La validación del hub es **pydantic sobre el cuerpo entero**: un solo campo mal tipado
|
|
275
|
+
responde 422 y rechaza el bundle completo **antes** de la persistencia por-entrada — no hay
|
|
276
|
+
degradación parcial. De ahí que el contrato de las secciones "de rescate" importe tanto como el de
|
|
277
|
+
los eventos.
|
|
278
|
+
|
|
279
|
+
`unmapped[]` — todo lo que el estado legacy trae y el catálogo del hub no sabe nombrar (spec §7:
|
|
280
|
+
*nada se pierde, nada bloquea*):
|
|
281
|
+
|
|
282
|
+
| Campo | Tipo exigido | Nota |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| `source_key` | string, 1..200 | `LegacyImported`, `event_catalog.py:446` |
|
|
285
|
+
| `original` | **dict** | un escalar responde 422 `dict_type` (issue #55) |
|
|
286
|
+
| `occurred_at` | ISO-8601 UTC | ancla del dato legacy, no la hora de la migración |
|
|
287
|
+
|
|
288
|
+
El estado legacy guarda escalares en cuatro de los sitios que caen aquí (fact fuera de catálogo,
|
|
289
|
+
elemento de `history[]` no-objeto, nota de release, `parallel_front.status: "closed"`), así que
|
|
290
|
+
`buildStateBundle` **envuelve** todo `original` que no sea ya un objeto JSON como `{"value": …}`.
|
|
291
|
+
La envoltura es del cable: los normalizadores conservan el valor legacy tal cual, y un `original`
|
|
292
|
+
que ya es dict viaja intacto (nunca se anida dos veces). `migrate --verify` comprueba el invariante
|
|
293
|
+
en seco, sin red.
|
|
@@ -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" ]
|
|
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
|
package/hooks/build/heartbeat.sh
CHANGED
|
@@ -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> —
|
|
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.
|
|
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
|
|
124
|
-
#
|
|
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
|
|
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
|
|
136
|
-
[ -n "$norm" ] && [ "$norm" != "{}" ] || return
|
|
137
|
-
runtime_projection_write "$norm" || return
|
|
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
|
-
|
|
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>
|