@trycore/spec-build-harness 0.14.2 → 0.16.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/GOVERNANCE.md +1 -1
- package/INSTALL.md +1 -1
- package/METODOLOGIA.md +15 -4
- package/README.md +43 -6
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +3 -0
- package/commands/build/front.md +8 -4
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +11 -2
- package/docs/getting-started.md +1 -1
- package/docs/hooks.md +4 -2
- package/docs/runtime/guia-modo-dual-y-migracion.md +79 -3
- package/docs/runtime/protocolo-cliente-runtime.md +66 -7
- package/hooks/build/lib/config.sh +56 -8
- package/hooks/build/lib/harness-meta.sh +72 -0
- package/hooks/build/lib/runtime-client.sh +120 -14
- package/hooks/build/lib/runtime-ops.sh +160 -27
- package/hooks/build/release-ops.sh +38 -12
- package/hooks/build/session-start.sh +5 -61
- package/hooks/build/slice-ops.sh +615 -41
- package/package.json +1 -1
- package/scripts/lib/front-plan.py +55 -6
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/test-config.sh +40 -0
- package/scripts/tests/test-front-plan.sh +63 -0
- package/scripts/tests/test-install.sh +2 -1
- package/scripts/tests/test-runtime-client.sh +49 -3
- package/scripts/tests/test-skill-ops.sh +516 -27
- package/scripts/tests/test-worktree-identity.sh +207 -0
- package/skills/managing-parallel-front/SKILL.md +35 -8
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
- **Token de proyecto:** emitido por un ADMIN en el hub, entregado al desarrollador, configurado una vez con `trycore-build init --runtime-url --runtime-token` (se guarda en `.claude/state/runtime.credentials`, `0600`, sembrado en `.gitignore` por `init`). Todas las llamadas: `Authorization: Bearer <token>`.
|
|
10
10
|
- **Registro:** `POST /agents/register {harness_version, asset_types}` → el servidor responde `{agent_key, project_id, harness_version, poll_interval_s, lease_ttl_s, manifest_hash, catalog{…}}`; el cliente (`runtime_register`) persiste hoy solo `agent_key`, `project_id`, `poll_interval_s`, `lease_ttl_s` y `manifest_hash` — `catalog` (tipos de asset) todavía no se persiste (el cliente ya envía `asset_types` desde `.claude/asset-types.json`, pero no consume el `catalog` de vuelta), `harness_version` no se re-persiste (ya se conoce localmente). Los intervalos los dicta el servidor (el cliente no los hardcodea). `409 incompatible_version` ⇒ mensaje claro con la versión mínima requerida (cuerpo `{error, min_version}`).
|
|
11
11
|
- **Identidad local:** `agent_key`/`project_id` persisten en `.claude/state/runtime.credentials` (`0600`, protegido en `.gitignore` desde el primer `init`); un mismo clon re-registrado reactiva su agente (no crea otro): `runtime_register` hace upsert sobre el fichero existente, nunca lo recrea desde cero.
|
|
12
|
+
- **Un token = un agente.** `POST /agents/register` **no crea identidad**: el `agent_key` es el `agent_id` que el ADMIN grabó en el token al emitirlo. Dos árboles con el mismo token son **el mismo agente** para el hub, y el segundo `claim` devuelve el slice que ya tiene reclamado el primero. Para dos agentes hacen falta **dos tokens**.
|
|
13
|
+
- **Identidad por árbol de trabajo (`git worktree`, issue #74).** El estado del arnés está en `.gitignore` y **no viaja** a un `git worktree add`. `config_root()` cae entonces al clon principal (`--git-common-dir`) para que el árbol nuevo no quede huérfano de contexto — pero ese fallback vale para **leer**, no para **escribir**: sin credenciales propias, el worktree firmaría cada acto con el `agent_key` del clon principal, reportaría los hashes del árbol equivocado y compartiría su heartbeat. Por eso:
|
|
14
|
+
- Toda operación de **escritura** (`slice-ops.sh claim|phase|gate|submit|archive|wiring|progress|checkpoint|fact|propose-asset|propose-epic|epic-writeback|graph-sync|escalate` y `release-ops.sh front-integration`) aborta con **rc 9** antes de tocar la red si la identidad es heredada.
|
|
15
|
+
- Las **lecturas** (`mode`, `next-step`, `status`, `epic-status`, `graph-sync-status`) conservan el fallback: son el diagnóstico del árbol que aún no se dio de alta. `status` lo encabeza con `identidad: ⚠ HEREDADA del clon principal … — solo lectura`.
|
|
16
|
+
- El alta es explícita: **`slice-ops.sh register`** (§10). Con ella, `ops_context_hashes`, el heartbeat, la outbox y los ledgers pasan a ser por-worktree solos — todos cuelgan de `config_root()`.
|
|
12
17
|
|
|
13
18
|
## 2. Mapeo hook → endpoint (la telemetría es del harness, no del modelo)
|
|
14
19
|
|
|
@@ -177,11 +182,11 @@ agente jamás publica contexto.**
|
|
|
177
182
|
| Situación | Comportamiento del cliente |
|
|
178
183
|
|---|---|
|
|
179
184
|
| `401/403` (token inválido/revocado) | Detener claims; mensaje accionable ("pide un token nuevo al ADMIN"); guards siguen operando con el último lock. |
|
|
180
|
-
| `409` en claim | Un reintento inmediato; luego informar "otro agente tomó la tarea" y pedir la siguiente. |
|
|
185
|
+
| `409` en claim | Un reintento inmediato; luego informar "otro agente tomó la tarea" y pedir la siguiente. Los motivos llegan **bajo `detail`**, no en la raíz: el **drift de contexto** se reconoce por FORMA (`detail` dict con `manifest_hash`, que es el hash al que sincronizar) y el resto por el texto que el hub construye en un solo sitio (contención → reintentable; «no hay trabajo sin contexto declarado» → `rc 6` sin reintento). Lo que el cliente enseña es la prosa del hub, nunca el JSON crudo. |
|
|
181
186
|
| `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. |
|
|
182
187
|
| Timeout/red caída | Modo offline (§4/§5); jamás bloquear PreToolUse ni el trabajo local. |
|
|
183
188
|
| `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`
|
|
189
|
+
| `409` de **grafo rancio** en `tasks/next` o en el carril directo | El hub lo emite como `{"detail": {"error": "graph_version_stale", "graph_version": <vigente>}}` — anidado bajo `detail`, que es donde FastAPI pone el cuerpo de un `HTTPException`. El cliente lo lee con `runtime_stale_graph_version`, que acepta esa forma y la plana `{"reason": "stale_graph"}` de una instancia anterior al contrato. Conducta: 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**). |
|
|
185
190
|
| Reloj local desviado | El cliente usa siempre `expires_at` del servidor, nunca calcula TTL con reloj propio. |
|
|
186
191
|
|
|
187
192
|
## 8. Seguridad del cliente
|
|
@@ -215,6 +220,7 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
|
|
|
215
220
|
| Comando | Superficie |
|
|
216
221
|
|---|---|
|
|
217
222
|
| `slice-ops.sh mode` | ninguna (lee `build-config.json`) |
|
|
223
|
+
| `slice-ops.sh register [--runtime-url U] [--force]` | `POST /agents/register` con un token **propio de este árbol**. Da de alta un worktree como agente distinto (§1). El token entra por **stdin** (prompt si hay TTY), nunca por argumento —`--token` sale con rc 2— porque en argv queda en el historial del shell y en `ps`; por lo mismo es un comando de terminal humano y **no** se invoca desde dentro de un claim (los hooks no tienen TTY y un `read` colgaría la llamada). Orden obligado: siembra el fichero local **primero** (si no, el upsert caería sobre las credenciales del clon principal y le pisaría la identidad), registra, y **verifica que el `agent_key` obtenido difiera** del del principal. Cualquier fallo revierte: el árbol queda con identidad propia y verificada, o exactamente como estaba; el fichero del clon principal no se toca nunca. `0` alta (o ya registrado) · `2` uso · `5` sin red · `6` mismo agente que el principal / token rechazado |
|
|
218
224
|
| `slice-ops.sh claim` | `POST /tasks/next` (§3); `--epic` falla explícito, rc 2 (no hay claim dirigido, §3.6) |
|
|
219
225
|
| `slice-ops.sh next-step` | ninguna: lo **deriva el cliente** desde la caché de proyección |
|
|
220
226
|
| `slice-ops.sh phase <to>` | evento(s) `phase_advanced {to}` en cadena (issue #52): el hub lleva la fase con orden ESTRICTO (`dor→change→red→green→refactor→smoke→api→data→dod→pr`, `domain.py` `PHASES`) y solo acepta la fase siguiente exacta. El cliente emite la cadena que falte desde la fase conocida (proyección ∪ `phase_advanced` pendientes en la cola) hasta `<to>`; idempotente (nada si ya está). `archived` no se reporta aquí (lo produce `archive`). Siempre por cola en `runtime` (tipo protegido de la cota: evictar un eslabón haría ilegal todo lo posterior); espejo en `dual` (`phase_advanced` ∈ `TIPOS_DE_ESPEJO`) |
|
|
@@ -235,6 +241,10 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
|
|
|
235
241
|
| `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
|
|
236
242
|
| `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) |
|
|
237
243
|
|
|
244
|
+
**Códigos de salida.** `0` ok · `2` uso · `3` legacy · `4` sin slice · `5` offline · `6` rechazado ·
|
|
245
|
+
`7` sin trabajo · `8` fuera del catálogo de espejo · **`9` identidad heredada** (#74: este árbol
|
|
246
|
+
opera con las credenciales del clon principal; da de alta el worktree con `register`).
|
|
247
|
+
|
|
238
248
|
**Forma del borrador de `propose-epic --file`:**
|
|
239
249
|
|
|
240
250
|
```json
|
|
@@ -271,13 +281,62 @@ El hub descarta los campos que no reconoce (`extra="ignore"`): mandar `acceptanc
|
|
|
271
281
|
`7` sin trabajo.
|
|
272
282
|
|
|
273
283
|
**Modo `dual`:** el fichero es primario y estos comandos **espejan** cada transición a
|
|
274
|
-
`POST /projects/{project_id}/mirror/transitions
|
|
275
|
-
|
|
276
|
-
|
|
284
|
+
`POST /projects/{project_id}/mirror/transitions`. El cuerpo es `MirrorTransitionsIn`:
|
|
285
|
+
|
|
286
|
+
```json
|
|
287
|
+
{"transitions": [{"client_event_id": "<uuid>", "epic_code": "EP-009",
|
|
288
|
+
"event_type": "gate_verdict", "payload": {…}}]}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
La identidad fichero-primaria viaja como `epic_code` **en el sobre**, que es donde el hub la lee;
|
|
292
|
+
dentro del `payload` no cabe nada que el schema del tipo no declare (`extra="forbid"`). Ni
|
|
293
|
+
`origin` ni `occurred_at` viajan: la marca de origen-espejo la estampa el servidor —lo que dijera
|
|
294
|
+
el cliente no elegiría el origen— y los relojes de cliente entran **solo** por el import. El
|
|
295
|
+
reporte es **por elemento** (`results[].status` ∈ `applied|duplicate|rejected` con su `reason`), y
|
|
296
|
+
un rechazo **no bloquea** el trabajo local: se avisa con el motivo del hub y queda como
|
|
297
|
+
discrepancia del comparador.
|
|
298
|
+
|
|
299
|
+
**Enumeración normativa del espejo** (`TIPOS_DE_ESPEJO`, replicada en `OPS_MIRROR_TYPES` con test
|
|
300
|
+
anti-drift): `slice_opened`, `phase_advanced`, `phase_reverted`, `gate_verdict`,
|
|
301
|
+
`wiring_item_updated`, `wiring_checklist_seeded`, `checkpoint_recorded`, `progress_noted`,
|
|
302
|
+
`handoff_recorded`. Un tipo fuera de ella **no gasta red**: se corta en local con `rc 8`. Ahí caen
|
|
303
|
+
`slice_escalated`, `slice_archived`, `slice_submitted` (existen en el catálogo del hub pero no son
|
|
304
|
+
espejables) y `release_verdict_reported` / `front_integration_reported` (no existen en el catálogo:
|
|
305
|
+
sus canales de espejo son trabajo **hub-side**).
|
|
306
|
+
|
|
307
|
+
### Re-sync del grafo (`graph-sync` / `graph-sync-status`, EP-OR-17)
|
|
308
|
+
|
|
309
|
+
El grafo del hub se siembra con el import de ADMIN y, hasta EP-OR-17, no volvía a moverse: las
|
|
310
|
+
épicas que discovery añadía después no tenían por dónde entrar, y toda transición de un slice cuya
|
|
311
|
+
épica falta en el grafo rebota con «la épica no existe en el grafo del proyecto». El carril nuevo
|
|
312
|
+
lo cierra sin dar al agente la potestad de mutar el grafo — **propone**, el hub calcula el delta y
|
|
313
|
+
un humano aprueba.
|
|
314
|
+
|
|
315
|
+
| Verbo y ruta | Cuerpo | Respuestas |
|
|
316
|
+
|---|---|---|
|
|
317
|
+
| `POST /projects/{id}/agent/graph-sync-proposals` | `{"bundle": {"epics": […]}, "graph_version": <vigente\|null>, "client_event_id": "<≤64>"}` | `201` propuesta `PROPOSED` · `200 {"empty_delta": true}` el grafo ya está al día · `409` `detail` dict = grafo rancio, `detail` string = el `client_event_id` ya identifica otro agregado · `422` bundle inválido (rechazo total, sin fila ni evento) · `404` anti-oráculo |
|
|
318
|
+
| `GET /projects/{id}/agent/graph-sync-proposals/{proposal_id}` | — | `200` con `status`, `delta`, `graph_version_applied` o `reject_reason` · `404` anti-oráculo |
|
|
319
|
+
|
|
320
|
+
- El **bundle** lo construye `scripts/lib/graph-bundle.py --for-sync` desde los docs de discovery.
|
|
321
|
+
`GraphSyncBundle` es `extra="forbid"` y admite **solo** `epics`: las claves del bundle de
|
|
322
|
+
preparación (`bundle_version`, `kind`, `project_ref`, `warnings`…) serían un `422`. Lo que en el
|
|
323
|
+
bundle de import es un aviso aquí es un **rechazo local** (`rc 2`): no hay humano revisando en
|
|
324
|
+
medio, y el hub rechaza el grafo entero ante una sola épica mal formada.
|
|
325
|
+
- El **`client_event_id` es determinista** (`<agent_key>:graph-sync:<n>`, con `n` = entradas del
|
|
326
|
+
ledger + 1; sha256 truncado del `agent_key` si no cabe en 64). El reintento lo reusa y el hub
|
|
327
|
+
deduplica, en vez de dejar dos propuestas del mismo grafo esperando aprobación.
|
|
328
|
+
- El **reintento del 409** lo hace el comando, no el despachador: refresca `/agent/context` y
|
|
329
|
+
vuelve a despachar **una vez**, con lo que el estampado toma la versión nueva. El bundle no
|
|
330
|
+
depende de la versión del grafo, solo el sello que lo acompaña.
|
|
331
|
+
- Tras la aprobación **no hay nada más que hacer**: las historias nuevas entran solas al reparto
|
|
332
|
+
del claim. La lógica de claim del cliente no cambia.
|
|
333
|
+
- El **ledger local** vive en `.claude/state/graph-sync-proposals.json` (protegido en
|
|
334
|
+
`.gitignore`), separado del de propuestas de épica: un re-sync no es una propuesta de épica.
|
|
277
335
|
|
|
278
336
|
**Lo que el agente NO hace** (superficies humanas, con PDP): cerrar una release, planificar/abrir/
|
|
279
|
-
drenar/cerrar un front,
|
|
280
|
-
propone y reporta; la persona decide en la consola del
|
|
337
|
+
drenar/cerrar un front, **aprobar o rechazar un re-sync del grafo**, subir el bundle de import del
|
|
338
|
+
grafo y publicar contexto. El arnés prepara, propone y reporta; la persona decide en la consola del
|
|
339
|
+
hub.
|
|
281
340
|
|
|
282
341
|
---
|
|
283
342
|
|
|
@@ -16,23 +16,71 @@
|
|
|
16
16
|
# Por eso, cuando el árbol actual no tiene estado, se cae al clon principal
|
|
17
17
|
# (--git-common-dir), que es donde vive de verdad. Idempotente para un clon
|
|
18
18
|
# normal: si el candidato ya tiene estado, se devuelve tal cual.
|
|
19
|
+
#
|
|
20
|
+
# [#74] Ese fallback vale para LEER, nunca para ESCRIBIR: quien vaya a transicionar algo
|
|
21
|
+
# tiene que preguntar antes por `config_is_inherited_root` (más abajo) y abortar, o estará
|
|
22
|
+
# operando con la identidad del clon principal sin saberlo.
|
|
19
23
|
config_root() {
|
|
20
24
|
local candidate
|
|
21
25
|
candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
22
26
|
[ -f "$candidate/.claude/state/runtime.credentials" ] && { echo "$candidate"; return; }
|
|
23
27
|
|
|
24
|
-
local
|
|
25
|
-
|
|
26
|
-
if [ -n "$
|
|
27
|
-
|
|
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
|
|
28
|
+
local main
|
|
29
|
+
main="$(config_main_clone_root)"
|
|
30
|
+
if [ -n "$main" ] && [ -f "$main/.claude/state/runtime.credentials" ]; then
|
|
31
|
+
echo "$main"; return
|
|
32
32
|
fi
|
|
33
33
|
echo "$candidate"
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
# ── Identidad por árbol de trabajo [#74] ─────────────────────────────────────────────
|
|
37
|
+
# El fallback de config_root es deliberado para LEER (un worktree sin estado propio sigue
|
|
38
|
+
# viendo el contexto del proyecto), pero es veneno para ESCRIBIR: un worktree sin
|
|
39
|
+
# credenciales propias impersona en silencio al agente del clon principal — reclama con su
|
|
40
|
+
# `agent_key`, reporta los hashes de SU árbol (no del propio), comparte su heartbeat y, al
|
|
41
|
+
# registrarse, le pisaría el fichero de credenciales. Estos tres helpers son la señal que
|
|
42
|
+
# permite a las operaciones de escritura fallar rápido en vez de suplantar.
|
|
43
|
+
|
|
44
|
+
# _config_phys <ruta> — forma FÍSICA de una ruta (resuelve symlinks). En macOS /var es un
|
|
45
|
+
# symlink a /private/var: comparar rutas sin normalizar mide cómo se escribió el path, no a
|
|
46
|
+
# qué apunta. Ante una ruta inaccesible devuelve la entrada tal cual — nunca lanza.
|
|
47
|
+
_config_phys() {
|
|
48
|
+
(cd "$1" 2>/dev/null && pwd -P) || printf '%s\n' "$1"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# config_candidate_root — la raíz que corresponde a ESTE árbol de trabajo, SIN fallback:
|
|
52
|
+
# `CLAUDE_PROJECT_DIR` o el toplevel del árbol actual. Es el otro extremo de la comparación
|
|
53
|
+
# de identidad (config_root es «de quién es el estado que voy a usar»; esto es «dónde estoy»).
|
|
54
|
+
config_candidate_root() {
|
|
55
|
+
local candidate
|
|
56
|
+
candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
57
|
+
_config_phys "$candidate"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# config_main_clone_root — raíz del clon principal según `--git-common-dir` ("" si no hay
|
|
61
|
+
# git). En un clon normal coincide con el propio árbol; en un worktree apunta al clon que lo
|
|
62
|
+
# creó. Es la única forma de leer la identidad del principal sin depender de config_root
|
|
63
|
+
# (que es justo lo que puede estar heredado).
|
|
64
|
+
config_main_clone_root() {
|
|
65
|
+
local candidate common main
|
|
66
|
+
candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
67
|
+
common="$(git -C "$candidate" rev-parse --git-common-dir 2>/dev/null)" || common=""
|
|
68
|
+
[ -n "$common" ] || { echo ""; return; }
|
|
69
|
+
case "$common" in /*) ;; *) common="$candidate/$common" ;; esac
|
|
70
|
+
main="$(cd "$common/.." 2>/dev/null && pwd -P)" || main=""
|
|
71
|
+
echo "$main"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# config_is_inherited_root — rc 0 si el estado que se va a usar NO es el de este árbol, es
|
|
75
|
+
# decir: config_root cayó al clon principal. rc 1 en un clon normal, en un worktree ya
|
|
76
|
+
# registrado y en un proyecto sin arnés (donde no hay identidad que heredar).
|
|
77
|
+
config_is_inherited_root() {
|
|
78
|
+
local here there
|
|
79
|
+
here="$(config_candidate_root)"
|
|
80
|
+
there="$(_config_phys "$(config_root)")"
|
|
81
|
+
[ -n "$here" ] && [ -n "$there" ] && [ "$here" != "$there" ]
|
|
82
|
+
}
|
|
83
|
+
|
|
36
84
|
# config_get <clave.punteada> <default>
|
|
37
85
|
config_get() {
|
|
38
86
|
local key="$1" def="$2" file
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# harness-meta.sh — metadatos del PAQUETE instalado (versión del arnés y catálogo de asset
|
|
3
|
+
# types). Extraído de session-start.sh [#74] para que `slice-ops.sh register` mande
|
|
4
|
+
# exactamente el mismo `harness_version` que el registro de arranque de sesión: dos lecturas
|
|
5
|
+
# distintas de la misma versión acabarían con un worktree rechazado por
|
|
6
|
+
# `incompatible_version` y el clon principal aceptado, o al revés.
|
|
7
|
+
# Fail-open, igual que config.sh: nunca lanza; sin fichero devuelve un valor neutro.
|
|
8
|
+
|
|
9
|
+
source "$(dirname "${BASH_SOURCE[0]}")/config.sh"
|
|
10
|
+
|
|
11
|
+
# harness_pkg_root — raíz del PAQUETE (no del proyecto). Los hooks se instalan como symlinks
|
|
12
|
+
# por fichero (linkChildren), así que hay que resolver la cadena de enlaces a mano:
|
|
13
|
+
# `readlink -f` no es portable al BSD readlink de macOS. Este fichero vive en
|
|
14
|
+
# `<pkg>/hooks/build/lib/`, de ahí los tres niveles.
|
|
15
|
+
harness_pkg_root() {
|
|
16
|
+
local src="${BASH_SOURCE[0]}" target i=0
|
|
17
|
+
while [ -L "$src" ] && [ "$i" -lt 10 ]; do
|
|
18
|
+
target="$(readlink "$src" 2>/dev/null)" || break
|
|
19
|
+
case "$target" in
|
|
20
|
+
/*) src="$target" ;;
|
|
21
|
+
*) src="$(dirname "$src")/$target" ;;
|
|
22
|
+
esac
|
|
23
|
+
i=$((i + 1))
|
|
24
|
+
done
|
|
25
|
+
(cd "$(dirname "$src")/../../.." >/dev/null 2>&1 && pwd -P) 2>/dev/null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# harness_version — versión instalada en el consumidor; si no, la del paquete.
|
|
29
|
+
# [#74] El marcador `.claude/.build-harness-version` está en .gitignore en modo symlink: NO
|
|
30
|
+
# viaja a un `git worktree add`. Por eso se mira también el clon principal antes de caer al
|
|
31
|
+
# paquete — un worktree recién dado de alta no tiene por qué mentir sobre su versión.
|
|
32
|
+
harness_version() {
|
|
33
|
+
local f pkg root
|
|
34
|
+
for root in "$(config_root)" "$(config_candidate_root)" "$(config_main_clone_root)"; do
|
|
35
|
+
[ -n "$root" ] || continue
|
|
36
|
+
f="$root/.claude/.build-harness-version"
|
|
37
|
+
[ -f "$f" ] && { tr -d ' \n' < "$f"; return; }
|
|
38
|
+
done
|
|
39
|
+
pkg="$(harness_pkg_root)"
|
|
40
|
+
if [ -n "$pkg" ] && [ -f "$pkg/VERSION" ]; then
|
|
41
|
+
tr -d ' \n' < "$pkg/VERSION"
|
|
42
|
+
return
|
|
43
|
+
fi
|
|
44
|
+
echo "0.0.0"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# harness_asset_types — el array `types` de asset-types.json del paquete (protocolo §6).
|
|
48
|
+
# Cadena de resolución: plugin > copia en .claude/ (la siembra es del sub-slice D) >
|
|
49
|
+
# paquete resuelto por symlink. Sin fichero: "[]" — el registro sigue funcionando.
|
|
50
|
+
harness_asset_types() {
|
|
51
|
+
local pkg candidates f
|
|
52
|
+
pkg="$(harness_pkg_root)"
|
|
53
|
+
candidates="${CLAUDE_PLUGIN_ROOT:-}/asset-types.json
|
|
54
|
+
$(config_root)/.claude/asset-types.json
|
|
55
|
+
${pkg:-}/asset-types.json"
|
|
56
|
+
while IFS= read -r f; do
|
|
57
|
+
case "$f" in /asset-types.json|asset-types.json) continue ;; esac
|
|
58
|
+
[ -f "$f" ] || continue
|
|
59
|
+
python3 - "$f" <<'PY' 2>/dev/null && return
|
|
60
|
+
import json,sys
|
|
61
|
+
try:
|
|
62
|
+
d=json.load(open(sys.argv[1]))
|
|
63
|
+
t=d.get("types")
|
|
64
|
+
print(json.dumps(t if isinstance(t,list) else [],ensure_ascii=False))
|
|
65
|
+
except Exception:
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
PY
|
|
68
|
+
done <<EOS
|
|
69
|
+
$candidates
|
|
70
|
+
EOS
|
|
71
|
+
echo "[]"
|
|
72
|
+
}
|
|
@@ -46,11 +46,39 @@ except Exception:
|
|
|
46
46
|
PY
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
# runtime_credentials_field_at <fichero> <clave> — lee un campo de un fichero de credenciales
|
|
50
|
+
# CONCRETO, sin pasar por config_root(). [#74] Es la única forma de mirar la identidad del clon
|
|
51
|
+
# principal desde un worktree: `runtime_field` resuelve por config_root(), que es justo lo que
|
|
52
|
+
# puede estar heredado — preguntarle «¿cuál es el agent_key del principal?» devolvía el propio.
|
|
53
|
+
runtime_credentials_field_at() {
|
|
54
|
+
local file="$1" key="$2"
|
|
55
|
+
[ -f "$file" ] || { echo ""; return; }
|
|
56
|
+
command -v python3 >/dev/null 2>&1 || { echo ""; return; }
|
|
57
|
+
python3 - "$file" "$key" <<'PY' 2>/dev/null
|
|
58
|
+
import json,sys
|
|
59
|
+
file,key=sys.argv[1],sys.argv[2]
|
|
60
|
+
try:
|
|
61
|
+
d=json.load(open(file))
|
|
62
|
+
v=d.get(key)
|
|
63
|
+
print(v if v is not None else "")
|
|
64
|
+
except Exception:
|
|
65
|
+
print("")
|
|
66
|
+
PY
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
# runtime_credentials_merge <json> — hace upsert de las claves del fragmento sobre el
|
|
50
70
|
# fichero existente (o uno nuevo). Escritura atómica + chmod 0600. Nunca imprime el token.
|
|
51
71
|
runtime_credentials_merge() {
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
runtime_credentials_merge_at "$(runtime_credentials_path)" "$1"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# runtime_credentials_merge_at <fichero> <json> — el mismo upsert sobre una RUTA EXPLÍCITA.
|
|
76
|
+
# [#74] `register` tiene que sembrar el fichero del worktree ANTES de que exista, y por tanto
|
|
77
|
+
# antes de que config_root() pueda anclar en local: pasando por runtime_credentials_path()
|
|
78
|
+
# habría hecho merge del token nuevo sobre el fichero del clon principal, pisándole la
|
|
79
|
+
# identidad al agente que está construyendo ahí.
|
|
80
|
+
runtime_credentials_merge_at() {
|
|
81
|
+
local file="$1" fragment="$2" dir
|
|
54
82
|
dir="$(dirname "$file")"
|
|
55
83
|
mkdir -p "$dir"
|
|
56
84
|
command -v python3 >/dev/null 2>&1 || return 1
|
|
@@ -78,6 +106,41 @@ except Exception:
|
|
|
78
106
|
PY
|
|
79
107
|
}
|
|
80
108
|
|
|
109
|
+
# runtime_credentials_seed_at <fichero> <runtime_url> — siembra credenciales en una RUTA
|
|
110
|
+
# EXPLÍCITA con el token tomado de `TRYCORE_REGISTER_TOKEN`. [#74] El token viaja por ENTORNO
|
|
111
|
+
# y no por argv a propósito: `runtime_credentials_merge_at` recibe el fragmento como argumento
|
|
112
|
+
# y en una máquina compartida un `ps` lo vería. Es el único camino por el que un secreto entra
|
|
113
|
+
# de nuevo al fichero, así que es el único que necesita esta precaución.
|
|
114
|
+
# Escribe un documento NUEVO, no un upsert: dar de alta un árbol es estrenar identidad, y
|
|
115
|
+
# conservar el `agent_key` anterior haría que una respuesta 200 sin `agent_key` pareciera un alta
|
|
116
|
+
# buena (`register --force` para rotar el token es justo el caso). Lo que el hub devuelva vuelve a
|
|
117
|
+
# entrar por `runtime_register`. Escritura atómica + chmod 0600.
|
|
118
|
+
runtime_credentials_seed_at() {
|
|
119
|
+
local file="$1" url="$2" dir
|
|
120
|
+
dir="$(dirname "$file")"
|
|
121
|
+
mkdir -p "$dir"
|
|
122
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
123
|
+
python3 - "$file" "$url" <<'PY' 2>/dev/null || return 1
|
|
124
|
+
import json,os,sys,tempfile
|
|
125
|
+
path,url=sys.argv[1],sys.argv[2]
|
|
126
|
+
tok=os.environ.get("TRYCORE_REGISTER_TOKEN","")
|
|
127
|
+
if not tok:
|
|
128
|
+
raise SystemExit(1)
|
|
129
|
+
d={"runtime_url":url,"project_token":tok}
|
|
130
|
+
dirn=os.path.dirname(path) or "."
|
|
131
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".runtime-credentials.",suffix=".tmp")
|
|
132
|
+
try:
|
|
133
|
+
with os.fdopen(fd,"w") as out:
|
|
134
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
135
|
+
os.chmod(tmp,0o600)
|
|
136
|
+
os.replace(tmp,path)
|
|
137
|
+
except Exception:
|
|
138
|
+
try: os.unlink(tmp)
|
|
139
|
+
except OSError: pass
|
|
140
|
+
raise
|
|
141
|
+
PY
|
|
142
|
+
}
|
|
143
|
+
|
|
81
144
|
runtime_url() {
|
|
82
145
|
runtime_field runtime_url
|
|
83
146
|
}
|
|
@@ -349,6 +412,41 @@ PY
|
|
|
349
412
|
# runtime_graph_clear_stale — el desfase dejó de existir (la proyección alcanzó al servidor).
|
|
350
413
|
runtime_graph_clear_stale() { rm -f "$(runtime_graph_status_path)" 2>/dev/null; return 0; }
|
|
351
414
|
|
|
415
|
+
# runtime_stale_graph_version <respuesta-json> — ¿este 409 dice que nuestro grafo va rancio?
|
|
416
|
+
# rc 0 = sí (imprime la versión vigente del servidor, o vacío si no la manda) · rc 1 = no.
|
|
417
|
+
#
|
|
418
|
+
# [EP-OR-17] Existe porque el cliente leía `{"reason":"stale_graph"}` en la RAÍZ y el hub emite
|
|
419
|
+
# `{"detail":{"error":"graph_version_stale","graph_version":N}}` — un HTTPException de FastAPI
|
|
420
|
+
# anida SIEMPRE bajo `detail`, y el literal `stale_graph` no aparece en ninguna parte de su
|
|
421
|
+
# backend. Con la lectura vieja, un claim con grafo rancio NO se reintentaba y una propuesta se
|
|
422
|
+
# apartaba como rechazo definitivo: dos caminos que existían para tolerar el desfase y que en
|
|
423
|
+
# realidad nunca se tomaban. Se aceptan las dos formas — si una instancia anterior al contrato
|
|
424
|
+
# emitiera la plana, seguiría entendiéndose.
|
|
425
|
+
# Fail-CLOSED a propósito, al revés que el resto del cliente: ante JSON ilegible o sin python3
|
|
426
|
+
# devuelve rc 1 («no es un stale»). Tratar un rechazo cualquiera como grafo rancio lo dejaría
|
|
427
|
+
# reintentando en bucle contra un hub que nunca va a aceptarlo.
|
|
428
|
+
runtime_stale_graph_version() {
|
|
429
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
430
|
+
printf '%s' "$1" | python3 -c '
|
|
431
|
+
import json,sys
|
|
432
|
+
try:
|
|
433
|
+
d=json.load(sys.stdin)
|
|
434
|
+
except Exception:
|
|
435
|
+
raise SystemExit(1)
|
|
436
|
+
if not isinstance(d,dict):
|
|
437
|
+
raise SystemExit(1)
|
|
438
|
+
det=d.get("detail")
|
|
439
|
+
fuente=det if isinstance(det,dict) else d
|
|
440
|
+
marca=fuente.get("error") or fuente.get("reason") or ""
|
|
441
|
+
if marca not in ("graph_version_stale","stale_graph"):
|
|
442
|
+
raise SystemExit(1)
|
|
443
|
+
v=fuente.get("graph_version")
|
|
444
|
+
if v is None:
|
|
445
|
+
v=d.get("graph_version")
|
|
446
|
+
sys.stdout.write("" if v is None else str(v))
|
|
447
|
+
' 2>/dev/null
|
|
448
|
+
}
|
|
449
|
+
|
|
352
450
|
RUNTIME_OUTBOX_MAX_BYTES=5242880
|
|
353
451
|
RUNTIME_OUTBOX_MAX_AGE_S=259200
|
|
354
452
|
# [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
|
|
@@ -408,7 +506,8 @@ PY
|
|
|
408
506
|
runtime_outbox_enforce_cap >/dev/null
|
|
409
507
|
}
|
|
410
508
|
|
|
411
|
-
# runtime_enqueue_direct <type> <method> <path> <payload_file> [stamp_graph_version]
|
|
509
|
+
# runtime_enqueue_direct <type> <method> <path> <payload_file> [stamp_graph_version]
|
|
510
|
+
# [client_event_id] — encola
|
|
412
511
|
# una petición del CARRIL DIRECTO [#62]: un hecho que NO viaja en el lote de `POST /events`
|
|
413
512
|
# sino a su propio endpoint (hoy: la propuesta de épica, hub#113). Comparte con la cola de
|
|
414
513
|
# eventos el directorio, el formato en disco, la cota y el sentinela de flush; lo único
|
|
@@ -419,18 +518,25 @@ PY
|
|
|
419
518
|
# [#63] Con `1` en el 5º argumento, el fichero se marca para que el DESPACHO le estampe la
|
|
420
519
|
# versión de grafo vigente. Sellarla al encolar sería un error: una propuesta que espera dos
|
|
421
520
|
# días en la cola offline saldría con la versión de hace dos días y nacería condenada al 409.
|
|
521
|
+
# [EP-OR-17] El 6º argumento fija el `client_event_id` en vez de generarlo: lo usa el re-sync
|
|
522
|
+
# del grafo, donde el reintento tiene que llevar la MISMA identidad para que el hub deduplique
|
|
523
|
+
# en vez de crear una segunda propuesta. Vacío (o ausente) = uuid4, como siempre.
|
|
422
524
|
runtime_enqueue_direct() {
|
|
423
|
-
local type="$1" method="$2" path="$3" pfile="$4" stamp="${5:-0}" dir
|
|
525
|
+
local type="$1" method="$2" path="$3" pfile="$4" stamp="${5:-0}" cid_fijo="${6:-}" dir
|
|
424
526
|
dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
|
|
425
527
|
command -v python3 >/dev/null 2>&1 || return 1
|
|
426
|
-
python3 - "$dir" "$type" "$method" "$path" "$pfile" "$stamp" <<'PY' 2>/dev/null || return 1
|
|
528
|
+
python3 - "$dir" "$type" "$method" "$path" "$pfile" "$stamp" "$cid_fijo" <<'PY' 2>/dev/null || return 1
|
|
427
529
|
import json,sys,os,tempfile,uuid,datetime
|
|
428
|
-
dir_,typ,method,path,pfile,stamp=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4],sys.argv[5],sys.argv[6]
|
|
530
|
+
dir_,typ,method,path,pfile,stamp,cid_fijo=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4],sys.argv[5],sys.argv[6],sys.argv[7]
|
|
429
531
|
try:
|
|
430
532
|
payload=json.load(open(pfile))
|
|
431
533
|
except Exception:
|
|
432
534
|
payload={}
|
|
433
|
-
cid
|
|
535
|
+
# [EP-OR-17] Un cid FIJADO por el productor es lo que hace idempotente el reintento: el hub
|
|
536
|
+
# deduplica por `client_event_id`, así que re-encolar el mismo re-sync tras un 409 de grafo
|
|
537
|
+
# rancio no crea una segunda propuesta del mismo grafo. Sin él se conserva el uuid4 de siempre,
|
|
538
|
+
# que es lo que quiere el carril de propuesta de épica (cada propuesta es una épica distinta).
|
|
539
|
+
cid=cid_fijo or str(uuid.uuid4())
|
|
434
540
|
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
435
541
|
d={"client_event_id":cid,"type":typ,"channel":"direct","method":method,"path":path,
|
|
436
542
|
"payload":payload,"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
@@ -779,7 +885,7 @@ PY
|
|
|
779
885
|
# propuesta de épica) y compartir el backoff del lote dejaría los hechos de dominio del slice
|
|
780
886
|
# esperando detrás de una propuesta. El ritmo real de reintento lo marca el ciclo del daemon.
|
|
781
887
|
_runtime_dispatch_direct() {
|
|
782
|
-
local f rc=0 resp status type path method payload gv server_gv n409 prev_gv stamped
|
|
888
|
+
local f rc=0 resp status type path method payload gv server_gv n409 prev_gv stamped
|
|
783
889
|
for f in "$@"; do
|
|
784
890
|
[ -f "$f" ] || continue
|
|
785
891
|
# [Ronda final] `gv` se reinicia POR ITERACIÓN: es `local` a la función y solo se asigna
|
|
@@ -821,17 +927,17 @@ _runtime_dispatch_direct() {
|
|
|
821
927
|
# escaneo del nombre bajo `set -u` en esta bash — `${type}` lo aísla.
|
|
822
928
|
_runtime_reject_file "$f" "el hub no expone $path (HTTP 404): esta instancia está sin soporte para «${type}» — actualiza el hub y vuelve a proponer" ;;
|
|
823
929
|
409)
|
|
824
|
-
# [Ronda de arreglo 1, Important 4] El
|
|
825
|
-
# un 409 por OTRA causa (
|
|
826
|
-
# rancio y tratarlo como tal apartaría la propuesta con un diagnóstico falso.
|
|
827
|
-
|
|
828
|
-
|
|
930
|
+
# [Ronda de arreglo 1, Important 4] El cuerpo decide, igual que en `_claim_once`:
|
|
931
|
+
# un 409 por OTRA causa (client_event_id de otro agregado, conflicto de estado…) no es
|
|
932
|
+
# un grafo rancio y tratarlo como tal apartaría la propuesta con un diagnóstico falso.
|
|
933
|
+
# [EP-OR-17] La lectura vive en `runtime_stale_graph_version`, que conoce la forma real
|
|
934
|
+
# del hub (`detail.error`) además de la plana legacy que este sitio leía a mano.
|
|
935
|
+
if server_gv="$(runtime_stale_graph_version "$resp")"; then
|
|
829
936
|
# No es un error de contrato: el grafo del hub cambió. Se anota el desfase (lo
|
|
830
937
|
# muestra `status`) y la petición se CONSERVA — el refresco del heartbeat (#61)
|
|
831
938
|
# traerá la versión nueva y el próximo ciclo la re-estampa. Con 3 rechazos seguidos
|
|
832
939
|
# se deja de insistir: a esas alturas no es una carrera, y un bucle silencioso es
|
|
833
940
|
# peor que un rechazo visible.
|
|
834
|
-
server_gv="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("graph_version") or "")' 2>/dev/null)"
|
|
835
941
|
runtime_graph_note_stale "$gv" "$server_gv"
|
|
836
942
|
n409="$(_runtime_direct_field "$f" graph_409)"
|
|
837
943
|
case "$n409" in ''|*[!0-9]*) n409=0 ;; esac
|