@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.
@@ -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` `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**). |
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` con la identidad fichero-primaria
275
- (`slice_ref: {epic_code, openspec_change, branch, phase}`) y `origin: "mirror"`. Un rechazo del
276
- reducer **no bloquea** el trabajo local: se avisa y queda como discrepancia del comparador.
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, subir el bundle de import del grafo y publicar contexto. El arnés prepara,
280
- propone y reporta; la persona decide en la consola del hub.
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 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
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
- local fragment="$1" file dir
53
- file="$(runtime_credentials_path)"
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] — encola
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=str(uuid.uuid4())
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 reason
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 `reason` decide, igual que en `_claim_once`:
825
- # un 409 por OTRA causa (propuesta duplicada, conflicto de estado…) no es un grafo
826
- # rancio y tratarlo como tal apartaría la propuesta con un diagnóstico falso.
827
- reason="$(printf '%s' "$resp" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("reason") or "")' 2>/dev/null)"
828
- if [ "$reason" = "stale_graph" ]; then
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