@trycore/spec-build-harness 0.14.2 → 0.15.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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "trycore-spec-build-harness",
4
4
  "displayName": "Trycore — Spec & Build Harness",
5
- "version": "0.14.2",
5
+ "version": "0.15.0",
6
6
  "description": "Arnés de construcción de dos loops (slice por épica + release gate) para Claude Code, con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
7
7
  "author": {
8
8
  "name": "Trycore",
package/README.md CHANGED
@@ -80,6 +80,7 @@ Gate de release que corre **una sola vez por versión** sobre el conjunto de sli
80
80
  | `/build:resume` | Rehidrata el slice activo **desde disco** (no desde la conversación) tras un reinicio de contexto: reconcilia el estado, lee `session_continuity`/`wiring_checklist`/`parallel_front` y determina la siguiente acción por prioridad. |
81
81
  | `/build:front` | Abre y coordina un **front paralelo** de épicas no fundacionales y disjuntas en archivos (`parallel_front`), cada una en su worktree/rama/PR. Delega en la skill `managing-parallel-front`. |
82
82
  | `/build:epic` | Propone una **épica incremental** al hub (modo `runtime`): el agente redacta y propone, el hub asigna el `EP-XXX` al aprobar y un humano aprueba; después `epicas.md` se escribe como **proyección** del grafo. En `legacy`/`dual` no aplica. |
83
+ | `/build:graph-sync` | Propone el **re-sync del grafo** del hub desde los docs de discovery (modo `runtime`): el hub calcula el delta y un humano lo aprueba — nada se aplica sin esa aprobación. Con `--check` lee el veredicto. Sin esto, el grafo del hub se quedaba en la foto del import inicial y los eventos de las épicas posteriores rebotaban. |
83
84
  | `/opsx:*` (10) | Ciclo OpenSpec: `explore` · `new` · `continue` · `apply` · `verify` · `archive` · `bulk-archive` · `ff` · `onboard` · `sync`. Detalle → [`docs/commands.md`](docs/commands.md). |
84
85
 
85
86
  ## Gestión de contexto
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.14.2
1
+ 0.15.0
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: "BUILD: Graph Sync"
3
+ description: Propone al hub el re-sync del grafo del proyecto desde los docs de discovery (epicas.md + HU-*.md). El hub calcula el delta y un humano lo aprueba en la consola; nada se aplica sin esa aprobación. Con --check no propone nada: solo consulta el veredicto de las propuestas ya enviadas. Adaptador delgado sobre slice-ops.sh graph-sync/graph-sync-status.
4
+ category: Workflow
5
+ tags: [build-harness, runtime, backlog, grafo, trycore]
6
+ ---
7
+
8
+ # /build:graph-sync — re-sincronizar el grafo del hub con los docs (la aprobación es humana)
9
+
10
+ El grafo del hub se siembra una vez, con el **import de admin** de `/build:onboard`. A partir de
11
+ ahí discovery sigue escribiendo: épicas nuevas en `docs/03-backlog/epicas.md`, historias nuevas
12
+ en las `HU-*.md`. Sin este comando, nada de eso llega al hub — y el síntoma es concreto: **todos
13
+ los eventos de un slice cuya épica no está en el grafo rebotan** con «la épica no existe en el
14
+ grafo del proyecto».
15
+
16
+ **Regla dura**: el arnés **propone**, un humano **aprueba**. Este comando no muta el grafo; deja
17
+ una propuesta con su delta calculado, y hasta que alguien la apruebe en la consola del hub no
18
+ cambia ni una arista.
19
+
20
+ **Entrada (opcional):** `--check`. Sin argumento, el comando **propone** el re-sync.
21
+
22
+ ## Despacho por argumento (léelo antes que nada)
23
+
24
+ - **Con `--check`** → ve directo al **§0** y luego **salta al §4**. `--check` es una consulta de
25
+ **solo lectura**: no construyas ningún bundle y no propongas nada (§1–§3 no se ejecutan, ni
26
+ siquiera "por si acaso"). Proponer aquí crearía una segunda propuesta del mismo grafo cada vez
27
+ que alguien comprueba el estado.
28
+ - **Sin argumento** → §0 → §1 → §2 → §3. El §4 se recorre después, cuando el humano haya
29
+ resuelto la propuesta en la consola (normalmente en otra invocación, con `--check`).
30
+
31
+ ## 0. Precondición
32
+
33
+ ```bash
34
+ bash .claude/hooks/build/slice-ops.sh mode
35
+ ```
36
+
37
+ - `legacy` → **STOP**: el grafo vive en los documentos y no hay hub con el que sincronizarlo.
38
+ - `dual` → **STOP** para proponer, pero aquí **sí hay hub**: el fichero local es el primario y el
39
+ grafo entra por el import de admin. No lo diagnostiques como un problema de conexión. El
40
+ `--dry-run` del §2 sí funciona: úsalo para revisar el grafo antes del corte a `runtime`.
41
+ - `runtime` → sigue.
42
+
43
+ ## 1. Construir el JSON de épicas desde los docs
44
+
45
+ Lee `docs/03-backlog/epicas.md` y las `HU-*.md` y escribe un fichero temporal con **todo** el
46
+ backlog vigente, no solo lo nuevo: el hub compara el bundle contra su grafo y calcula el delta,
47
+ así que mandar un subconjunto no borra nada pero tampoco describe el proyecto.
48
+
49
+ ```json
50
+ {"project_ref": "<nombre del proyecto>",
51
+ "epics": [{"code": "EP-001", "title": "…", "layer": "foundational",
52
+ "files_scope": ["src/core/**"], "depends_on": [],
53
+ "stories": [{"id": "HU-001", "title": "…"}]},
54
+ {"code": "EP-002", "title": "…", "layer": "business",
55
+ "files_scope": ["src/pagos/**"], "depends_on": ["EP-001"],
56
+ "stories": [{"id": "HU-011", "title": "…"}]}],
57
+ "release_lines": [{"id": "R1-mvp", "epics": ["EP-001", "EP-002"]}]}
58
+ ```
59
+
60
+ Es el **mismo formato** que la fase de grafo de `/build:onboard`: si ya lo generaste allí,
61
+ reutilízalo. A diferencia de las propuestas de épica, aquí el `code` **sí** viaja: el re-sync es
62
+ la puerta de lo que ya tiene identidad en los documentos.
63
+
64
+ **No inventes ninguna épica ni ninguna capa.** Si un dato falta en los docs, falta en el grafo:
65
+ se corrige en discovery, no aquí.
66
+
67
+ ## 2. Revisar antes de proponer (`--dry-run`)
68
+
69
+ ```bash
70
+ bash .claude/hooks/build/slice-ops.sh graph-sync \
71
+ --file /tmp/epics.json --from-docs docs/03-backlog/epicas.md --dry-run
72
+ ```
73
+
74
+ **Pasa siempre `--from-docs`**: relee el campo `**Depende de**` de cada sección de épica y une
75
+ ese grafo al del JSON. Es la única lectura fiable de las dependencias, que la metodología escribe
76
+ como prosa; sin él, un backlog lleno de dependencias sale con `depends_on: []` y el Lienzo del
77
+ hub queda sin una sola arista.
78
+
79
+ `--dry-run` imprime el bundle ya proyectado al contrato del hub y **no envía nada**. Revísalo con
80
+ el usuario: número de épicas, capas, aristas.
81
+
82
+ Un `rc 2` aquí no es un fallo del comando: es el bundle incumpliendo el contrato del hub, con un
83
+ motivo por línea que nombra la épica y el campo (`layer` ausente, historia sin `code`, techos
84
+ excedidos). Corrígelo **en los docs de discovery** y repite. El hub rechaza el grafo entero ante
85
+ una sola épica mal formada, así que estas comprobaciones se hacen aquí para no gastar un viaje.
86
+
87
+ ## 3. Proponer
88
+
89
+ ```bash
90
+ bash .claude/hooks/build/slice-ops.sh graph-sync \
91
+ --file /tmp/epics.json --from-docs docs/03-backlog/epicas.md
92
+ ```
93
+
94
+ | rc | Significado | Qué haces |
95
+ |---|---|---|
96
+ | `0` | Propuesta creada (el comando enseña el `id` y el delta), **o** el grafo ya estaba al día | Si hay `id`, dile al usuario que queda **pendiente de aprobación humana** en la consola. Si dice «ya está al día», no hay nada que hacer |
97
+ | `5` | Encolada, aún sin entregar (sin conexión, o el daemon estaba drenando la cola) | Avisa de que se despachará sola y de que `--check` la sigue |
98
+ | `6` | El hub la rechazó (422 con el motivo, 404, o falta `project_id`) | Muestra la razón; **no** reintentes en bucle |
99
+ | `2` | El bundle no cumple el contrato, o falta `--file` | Corrige lo que indique el mensaje, en los docs |
100
+ | `3` | `legacy`/`dual` | Vuelve al paso 0 |
101
+
102
+ Un **409 de grafo rancio** no lo verás como error: el comando refresca el contexto y reintenta
103
+ una vez con el mismo identificador, para que el hub deduplique en vez de crear una segunda
104
+ propuesta del mismo grafo.
105
+
106
+ ## 4. Después de la decisión — `/build:graph-sync --check`
107
+
108
+ **Aquí aterriza `--check`.** Es mecánico y de solo lectura:
109
+
110
+ ```bash
111
+ bash .claude/hooks/build/slice-ops.sh graph-sync-status
112
+ ```
113
+
114
+ - `APPROVED` (con la versión de grafo aplicada) → **no hay nada más que hacer**. Las historias
115
+ nuevas entran solas al reparto del claim: no hay que reclamar de otra forma ni tocar el estado.
116
+ - `REJECTED` (con el motivo que escribió el ADMIN) → **léeselo al usuario**. El motivo existe
117
+ precisamente para que esta terminal lo lea. Corrige en discovery y vuelve a proponer.
118
+ - `PROPOSED` → sigue esperando decisión humana. No propongas otra vez.
119
+ - `QUEUED` → aún sin despachar; el daemon de heartbeat la lleva.
120
+
121
+ ## Guardrails
122
+
123
+ - El arnés **propone**; la aprobación es humana (gobierno, METODOLOGIA §10 regla 8). Ni el
124
+ modelo ni el arnés aprueban un cambio de grafo.
125
+ - **No edites `docs/03-backlog/epicas.md` para «arreglar» el bundle.** El fichero es la fuente;
126
+ si el bundle no cumple, lo que está mal es la fuente, y se corrige con el usuario delante.
127
+ - **No propongas dos veces sin mirar `--check`.** Dos propuestas del mismo grafo esperando
128
+ aprobación es exactamente el ruido que este carril evita.
129
+ - Un `REJECTED` **no** se resuelve reintentando: se resuelve leyendo el motivo.
@@ -230,6 +230,9 @@ function syncGitignoreBlock(targetDir, mode) {
230
230
  // sin esto, un consumidor los commitea por descuido.
231
231
  '.claude/state/epic-proposals.json',
232
232
  '.claude/state/graph-status.json',
233
+ // [EP-OR-17] El ledger del re-sync del grafo es lo mismo: el rastro local de una
234
+ // conversación con el hub, no estado del proyecto.
235
+ '.claude/state/graph-sync-proposals.json',
233
236
  // [Ronda final · C5] Estado del daemon de heartbeat (EP-OR-08-B): un pid y unos ppids
234
237
  // solo significan algo en LA máquina que los escribió. Commiteados, otro clon recibe un
235
238
  // pidfile ajeno que el relanzamiento da por bueno hasta el `kill -0`, y un
package/docs/commands.md CHANGED
@@ -129,7 +129,7 @@ El canal CLI namespacea por subcarpeta: `.claude/commands/opsx/` → `/opsx:*` y
129
129
  |---|---|
130
130
  | `/build:prototype` | Genera o amplía el **prototipo HTML de referencia** (el `DESIGN_SOURCE`) en `docs/05-prototipo/` (`DESIGN.md` + `tokens.css` + `manifest.json` + un HTML autocontenido por pantalla). Adaptador delgado: **delega** en la skill `prototyping-screens`. Dos modos — **greenfield** (`/build:prototype`): inventario de pantallas desde PRD/mapa/historias → confirmación humana → dirección estética (manual de marca o 2-3 variantes a elección humana) → generación por lotes con auto-verificación visual; **feature** (`/build:prototype <épica>`): pantallas nuevas coherentes con el UX/UI **ya implementado**, con **precondición dura** (app corriendo + MCP de inspección de UI: extrae CSS computado real, screenshots en 3 viewports y estructura; sin degradación estática). Es **outer-loop** (antes de abrir slices). **La skill genera; el humano aprueba**: las pantallas nacen `borrador`, solo las `aprobada` son fuente de verdad (las lee `ux-fidelity-reviewer` vía `manifest.json`) y `design_source.confirmed` sigue siendo humano. |
131
131
 
132
- ### `/build:claim`, `/build:status`, `/build:escalate`, `/build:epic` — superficie de agente del runtime (beta, opt-in)
132
+ ### `/build:claim`, `/build:status`, `/build:escalate`, `/build:epic`, `/build:graph-sync` — superficie de agente del runtime (beta, opt-in)
133
133
 
134
134
  Adaptadores delgados sobre `slice-ops.sh`; en modo `legacy` (default) devuelven `rc 3` y remiten al
135
135
  protocolo del fichero (`/build:slice`) — no rompen el flujo normal si el proyecto no está migrado.
@@ -140,6 +140,7 @@ protocolo del fichero (`/build:slice`) — no rompen el flujo normal si el proye
140
140
  | `/build:status` | Informe de solo lectura: modo, slice activo, gates, wiring failing, versión de contexto, lease y cola de eventos pendientes. Nunca transiciona nada. |
141
141
  | `/build:escalate <razón>` | Registra un bloqueo en el runtime (`escalation_raised`) y devuelve la decisión a un humano — recortar, diferir o desbloquear **nunca** lo decide el modelo (mismo principio que la regla 8 de METODOLOGIA §10). |
142
142
  | `/build:epic` | Propone una épica **incremental de construcción** al hub y, tras la aprobación humana, la escribe en `docs/03-backlog/epicas.md` con el `EP-XXX` que asignó el hub (`/build:epic --check`). El agente **no elige** el código: elegirlo mirando el fichero es lo que hace colisionar a dos terminales. La descomposición inicial del backlog sigue siendo de discovery. |
143
+ | `/build:graph-sync` | Propone al hub el **re-sync del grafo** desde los docs de discovery (`epicas.md` + `HU-*.md`): el hub calcula el delta y un humano lo aprueba en la consola — **nada se aplica** sin esa aprobación. Existe porque el grafo del hub se sembraba una vez, con el import de admin, y todo lo que discovery escribía después no tenía por dónde entrar: el síntoma es que **cada evento de un slice cuya épica falta en el grafo rebota**. Con `--check` solo consulta el veredicto (`APPROVED` con la versión aplicada, o `REJECTED` con el motivo que escribió el ADMIN). Tras la aprobación no hay nada que hacer: las historias nuevas entran solas al reparto del claim. |
143
144
 
144
145
  ---
145
146
 
@@ -120,7 +120,7 @@ arman peticiones a mano. En `dual`, cada transición también se **espeja** al s
120
120
  identidad del fichero (que sigue mandando). Si el espejo falla (red caída, servidor rechaza), el
121
121
  trabajo local **nunca se bloquea** — se avisa y queda como discrepancia para el comparador.
122
122
 
123
- `slice-ops.sh` tiene **17 subcomandos** (los conducen las skills; los listamos para que puedas
123
+ `slice-ops.sh` tiene **19 subcomandos** (los conducen las skills; los listamos para que puedas
124
124
  leer un log o depurar, no para que los teclees):
125
125
 
126
126
  | Subcomando | Para qué |
@@ -136,12 +136,47 @@ leer un log o depurar, no para que los teclees):
136
136
  | `fact` | reporta un hecho de proyecto confirmado |
137
137
  | `propose-asset` | propone un documento al plano de contexto (lo publica un ADMIN) |
138
138
  | `propose-epic` · `epic-status` · `epic-writeback` | proponen una épica, consultan su estado y proyectan la aprobada a `epicas.md` |
139
+ | `graph-sync` · `graph-sync-status` | proponen el re-sync del grafo desde los docs de discovery y leen el veredicto humano (solo en `runtime`) |
139
140
  | `status` · `escalate` | informe local del agente y registro de un bloqueo |
140
141
 
141
142
  Del lado release, `release-ops.sh` añade `verdict` y `front-integration`.
142
143
 
143
144
  **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
+ `4` sin slice · `5` offline · `6` rechazado · `7` sin trabajo · `8` tipo no espejable.
146
+
147
+ ### Qué espeja el modo `dual` — y qué no (0.15.0)
148
+
149
+ El espejo cubre la **enumeración normativa** del hub, ni más ni menos: `slice_opened`,
150
+ `phase_advanced`, `phase_reverted`, `gate_verdict`, `wiring_item_updated`,
151
+ `wiring_checklist_seeded`, `checkpoint_recorded`, `progress_noted` y `handoff_recorded`. Todo eso
152
+ viaja como `{"transitions": [{client_event_id, epic_code, event_type, payload}]}`, con la épica
153
+ del `build-state.json` local en el **sobre**, y el hub responde un reporte **por elemento** cuyo
154
+ motivo de rechazo el cliente te enseña tal cual.
155
+
156
+ Lo que **no** se espeja, y por qué — son huecos del **hub**, no del arnés, y el cliente los corta
157
+ en local (`rc 8`) en vez de gastar una llamada para recibir un rechazo:
158
+
159
+ | No espejable | Motivo |
160
+ |---|---|
161
+ | `slice_escalated` · `slice_archived` · `slice_submitted` | Existen en el catálogo del hub pero no heredan de `_TransicionDeEspejo`: la ingesta espejo no los acepta. Una discrepancia dual sigue siendo **visible** donde la lees —el aviso del hook `Stop`—, pero no queda registrada en el hub. |
162
+ | `release_verdict_reported` · `front_integration_reported` | No existen en el catálogo del hub, y la superficie síncrona de los veredictos de release es de **gobierno humano**. En `dual` el veredicto se anota en local y el comando te lo dice con esas palabras; quedará reflejado al pasar a `runtime`. |
163
+
164
+ Cerrar estos huecos requiere un tipo nuevo en el catálogo del hub con su rama de reducer: es
165
+ trabajo hub-side. Mientras tanto el arnés lo **nombra** en vez de fallar en silencio.
166
+
167
+ ### Cuando el hub no conoce tu épica (0.15.0)
168
+
169
+ Si el espejo rechaza con «la épica no existe en el grafo del proyecto», el grafo del hub se quedó
170
+ en la foto del import inicial. La salida es proponer el re-sync:
171
+
172
+ ```bash
173
+ /build:graph-sync # construye el bundle desde los docs y lo propone
174
+ /build:graph-sync --check # lee el veredicto: APPROVED, o REJECTED con su motivo
175
+ ```
176
+
177
+ En `dual` el carril de propuesta no opera (el fichero es primario y el grafo entra por el import
178
+ de admin), pero `slice-ops.sh graph-sync --file epics.json --dry-run` **sí** funciona: úsalo para
179
+ revisar el grafo antes del corte a `runtime`.
145
180
 
146
181
  ### Lo que aportó la 0.14.0
147
182
 
@@ -177,11 +177,11 @@ agente jamás publica contexto.**
177
177
  | Situación | Comportamiento del cliente |
178
178
  |---|---|
179
179
  | `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. |
180
+ | `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
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. |
182
182
  | Timeout/red caída | Modo offline (§4/§5); jamás bloquear PreToolUse ni el trabajo local. |
183
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**). |
184
+ | `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
185
  | Reloj local desviado | El cliente usa siempre `expires_at` del servidor, nunca calcula TTL con reloj propio. |
186
186
 
187
187
  ## 8. Seguridad del cliente
@@ -271,13 +271,62 @@ El hub descarta los campos que no reconoce (`extra="ignore"`): mandar `acceptanc
271
271
  `7` sin trabajo.
272
272
 
273
273
  **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.
274
+ `POST /projects/{project_id}/mirror/transitions`. El cuerpo es `MirrorTransitionsIn`:
275
+
276
+ ```json
277
+ {"transitions": [{"client_event_id": "<uuid>", "epic_code": "EP-009",
278
+ "event_type": "gate_verdict", "payload": {…}}]}
279
+ ```
280
+
281
+ La identidad fichero-primaria viaja como `epic_code` **en el sobre**, que es donde el hub la lee;
282
+ dentro del `payload` no cabe nada que el schema del tipo no declare (`extra="forbid"`). Ni
283
+ `origin` ni `occurred_at` viajan: la marca de origen-espejo la estampa el servidor —lo que dijera
284
+ el cliente no elegiría el origen— y los relojes de cliente entran **solo** por el import. El
285
+ reporte es **por elemento** (`results[].status` ∈ `applied|duplicate|rejected` con su `reason`), y
286
+ un rechazo **no bloquea** el trabajo local: se avisa con el motivo del hub y queda como
287
+ discrepancia del comparador.
288
+
289
+ **Enumeración normativa del espejo** (`TIPOS_DE_ESPEJO`, replicada en `OPS_MIRROR_TYPES` con test
290
+ anti-drift): `slice_opened`, `phase_advanced`, `phase_reverted`, `gate_verdict`,
291
+ `wiring_item_updated`, `wiring_checklist_seeded`, `checkpoint_recorded`, `progress_noted`,
292
+ `handoff_recorded`. Un tipo fuera de ella **no gasta red**: se corta en local con `rc 8`. Ahí caen
293
+ `slice_escalated`, `slice_archived`, `slice_submitted` (existen en el catálogo del hub pero no son
294
+ espejables) y `release_verdict_reported` / `front_integration_reported` (no existen en el catálogo:
295
+ sus canales de espejo son trabajo **hub-side**).
296
+
297
+ ### Re-sync del grafo (`graph-sync` / `graph-sync-status`, EP-OR-17)
298
+
299
+ El grafo del hub se siembra con el import de ADMIN y, hasta EP-OR-17, no volvía a moverse: las
300
+ épicas que discovery añadía después no tenían por dónde entrar, y toda transición de un slice cuya
301
+ épica falta en el grafo rebota con «la épica no existe en el grafo del proyecto». El carril nuevo
302
+ lo cierra sin dar al agente la potestad de mutar el grafo — **propone**, el hub calcula el delta y
303
+ un humano aprueba.
304
+
305
+ | Verbo y ruta | Cuerpo | Respuestas |
306
+ |---|---|---|
307
+ | `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 |
308
+ | `GET /projects/{id}/agent/graph-sync-proposals/{proposal_id}` | — | `200` con `status`, `delta`, `graph_version_applied` o `reject_reason` · `404` anti-oráculo |
309
+
310
+ - El **bundle** lo construye `scripts/lib/graph-bundle.py --for-sync` desde los docs de discovery.
311
+ `GraphSyncBundle` es `extra="forbid"` y admite **solo** `epics`: las claves del bundle de
312
+ preparación (`bundle_version`, `kind`, `project_ref`, `warnings`…) serían un `422`. Lo que en el
313
+ bundle de import es un aviso aquí es un **rechazo local** (`rc 2`): no hay humano revisando en
314
+ medio, y el hub rechaza el grafo entero ante una sola épica mal formada.
315
+ - El **`client_event_id` es determinista** (`<agent_key>:graph-sync:<n>`, con `n` = entradas del
316
+ ledger + 1; sha256 truncado del `agent_key` si no cabe en 64). El reintento lo reusa y el hub
317
+ deduplica, en vez de dejar dos propuestas del mismo grafo esperando aprobación.
318
+ - El **reintento del 409** lo hace el comando, no el despachador: refresca `/agent/context` y
319
+ vuelve a despachar **una vez**, con lo que el estampado toma la versión nueva. El bundle no
320
+ depende de la versión del grafo, solo el sello que lo acompaña.
321
+ - Tras la aprobación **no hay nada más que hacer**: las historias nuevas entran solas al reparto
322
+ del claim. La lógica de claim del cliente no cambia.
323
+ - El **ledger local** vive en `.claude/state/graph-sync-proposals.json` (protegido en
324
+ `.gitignore`), separado del de propuestas de épica: un re-sync no es una propuesta de épica.
277
325
 
278
326
  **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.
327
+ drenar/cerrar un front, **aprobar o rechazar un re-sync del grafo**, subir el bundle de import del
328
+ grafo y publicar contexto. El arnés prepara, propone y reporta; la persona decide en la consola del
329
+ hub.
281
330
 
282
331
  ---
283
332
 
@@ -349,6 +349,41 @@ PY
349
349
  # runtime_graph_clear_stale — el desfase dejó de existir (la proyección alcanzó al servidor).
350
350
  runtime_graph_clear_stale() { rm -f "$(runtime_graph_status_path)" 2>/dev/null; return 0; }
351
351
 
352
+ # runtime_stale_graph_version <respuesta-json> — ¿este 409 dice que nuestro grafo va rancio?
353
+ # rc 0 = sí (imprime la versión vigente del servidor, o vacío si no la manda) · rc 1 = no.
354
+ #
355
+ # [EP-OR-17] Existe porque el cliente leía `{"reason":"stale_graph"}` en la RAÍZ y el hub emite
356
+ # `{"detail":{"error":"graph_version_stale","graph_version":N}}` — un HTTPException de FastAPI
357
+ # anida SIEMPRE bajo `detail`, y el literal `stale_graph` no aparece en ninguna parte de su
358
+ # backend. Con la lectura vieja, un claim con grafo rancio NO se reintentaba y una propuesta se
359
+ # apartaba como rechazo definitivo: dos caminos que existían para tolerar el desfase y que en
360
+ # realidad nunca se tomaban. Se aceptan las dos formas — si una instancia anterior al contrato
361
+ # emitiera la plana, seguiría entendiéndose.
362
+ # Fail-CLOSED a propósito, al revés que el resto del cliente: ante JSON ilegible o sin python3
363
+ # devuelve rc 1 («no es un stale»). Tratar un rechazo cualquiera como grafo rancio lo dejaría
364
+ # reintentando en bucle contra un hub que nunca va a aceptarlo.
365
+ runtime_stale_graph_version() {
366
+ command -v python3 >/dev/null 2>&1 || return 1
367
+ printf '%s' "$1" | python3 -c '
368
+ import json,sys
369
+ try:
370
+ d=json.load(sys.stdin)
371
+ except Exception:
372
+ raise SystemExit(1)
373
+ if not isinstance(d,dict):
374
+ raise SystemExit(1)
375
+ det=d.get("detail")
376
+ fuente=det if isinstance(det,dict) else d
377
+ marca=fuente.get("error") or fuente.get("reason") or ""
378
+ if marca not in ("graph_version_stale","stale_graph"):
379
+ raise SystemExit(1)
380
+ v=fuente.get("graph_version")
381
+ if v is None:
382
+ v=d.get("graph_version")
383
+ sys.stdout.write("" if v is None else str(v))
384
+ ' 2>/dev/null
385
+ }
386
+
352
387
  RUNTIME_OUTBOX_MAX_BYTES=5242880
353
388
  RUNTIME_OUTBOX_MAX_AGE_S=259200
354
389
  # [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
@@ -408,7 +443,8 @@ PY
408
443
  runtime_outbox_enforce_cap >/dev/null
409
444
  }
410
445
 
411
- # runtime_enqueue_direct <type> <method> <path> <payload_file> [stamp_graph_version] — encola
446
+ # runtime_enqueue_direct <type> <method> <path> <payload_file> [stamp_graph_version]
447
+ # [client_event_id] — encola
412
448
  # una petición del CARRIL DIRECTO [#62]: un hecho que NO viaja en el lote de `POST /events`
413
449
  # sino a su propio endpoint (hoy: la propuesta de épica, hub#113). Comparte con la cola de
414
450
  # eventos el directorio, el formato en disco, la cota y el sentinela de flush; lo único
@@ -419,18 +455,25 @@ PY
419
455
  # [#63] Con `1` en el 5º argumento, el fichero se marca para que el DESPACHO le estampe la
420
456
  # versión de grafo vigente. Sellarla al encolar sería un error: una propuesta que espera dos
421
457
  # días en la cola offline saldría con la versión de hace dos días y nacería condenada al 409.
458
+ # [EP-OR-17] El 6º argumento fija el `client_event_id` en vez de generarlo: lo usa el re-sync
459
+ # del grafo, donde el reintento tiene que llevar la MISMA identidad para que el hub deduplique
460
+ # en vez de crear una segunda propuesta. Vacío (o ausente) = uuid4, como siempre.
422
461
  runtime_enqueue_direct() {
423
- local type="$1" method="$2" path="$3" pfile="$4" stamp="${5:-0}" dir
462
+ local type="$1" method="$2" path="$3" pfile="$4" stamp="${5:-0}" cid_fijo="${6:-}" dir
424
463
  dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
425
464
  command -v python3 >/dev/null 2>&1 || return 1
426
- python3 - "$dir" "$type" "$method" "$path" "$pfile" "$stamp" <<'PY' 2>/dev/null || return 1
465
+ python3 - "$dir" "$type" "$method" "$path" "$pfile" "$stamp" "$cid_fijo" <<'PY' 2>/dev/null || return 1
427
466
  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]
467
+ 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
468
  try:
430
469
  payload=json.load(open(pfile))
431
470
  except Exception:
432
471
  payload={}
433
- cid=str(uuid.uuid4())
472
+ # [EP-OR-17] Un cid FIJADO por el productor es lo que hace idempotente el reintento: el hub
473
+ # deduplica por `client_event_id`, así que re-encolar el mismo re-sync tras un 409 de grafo
474
+ # rancio no crea una segunda propuesta del mismo grafo. Sin él se conserva el uuid4 de siempre,
475
+ # que es lo que quiere el carril de propuesta de épica (cada propuesta es una épica distinta).
476
+ cid=cid_fijo or str(uuid.uuid4())
434
477
  ts=datetime.datetime.now(datetime.timezone.utc)
435
478
  d={"client_event_id":cid,"type":typ,"channel":"direct","method":method,"path":path,
436
479
  "payload":payload,"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
@@ -779,7 +822,7 @@ PY
779
822
  # propuesta de épica) y compartir el backoff del lote dejaría los hechos de dominio del slice
780
823
  # esperando detrás de una propuesta. El ritmo real de reintento lo marca el ciclo del daemon.
781
824
  _runtime_dispatch_direct() {
782
- local f rc=0 resp status type path method payload gv server_gv n409 prev_gv stamped reason
825
+ local f rc=0 resp status type path method payload gv server_gv n409 prev_gv stamped
783
826
  for f in "$@"; do
784
827
  [ -f "$f" ] || continue
785
828
  # [Ronda final] `gv` se reinicia POR ITERACIÓN: es `local` a la función y solo se asigna
@@ -821,17 +864,17 @@ _runtime_dispatch_direct() {
821
864
  # escaneo del nombre bajo `set -u` en esta bash — `${type}` lo aísla.
822
865
  _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
866
  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
867
+ # [Ronda de arreglo 1, Important 4] El cuerpo decide, igual que en `_claim_once`:
868
+ # un 409 por OTRA causa (client_event_id de otro agregado, conflicto de estado…) no es
869
+ # un grafo rancio y tratarlo como tal apartaría la propuesta con un diagnóstico falso.
870
+ # [EP-OR-17] La lectura vive en `runtime_stale_graph_version`, que conoce la forma real
871
+ # del hub (`detail.error`) además de la plana legacy que este sitio leía a mano.
872
+ if server_gv="$(runtime_stale_graph_version "$resp")"; then
829
873
  # No es un error de contrato: el grafo del hub cambió. Se anota el desfase (lo
830
874
  # muestra `status`) y la petición se CONSERVA — el refresco del heartbeat (#61)
831
875
  # traerá la versión nueva y el próximo ciclo la re-estampa. Con 3 rechazos seguidos
832
876
  # se deja de insistir: a esas alturas no es una carrera, y un bucle silencioso es
833
877
  # 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
878
  runtime_graph_note_stale "$gv" "$server_gv"
836
879
  n409="$(_runtime_direct_field "$f" graph_409)"
837
880
  case "$n409" in ''|*[!0-9]*) n409=0 ;; esac