@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.
- package/.claude-plugin/plugin.json +1 -1
- package/INSTALL.md +56 -3
- package/METODOLOGIA.md +6 -1
- package/README.md +46 -2
- 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 +34 -9
- 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/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/docs/commands.md +3 -2
- package/docs/getting-started.md +11 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +89 -9
- package/docs/runtime/protocolo-cliente-runtime.md +64 -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/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 +456 -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
|
@@ -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
|
|
58
|
+
## 4. Activar `dual` en un proyecto ya instalado
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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" ]
|
|
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>
|