@trycore/spec-build-harness 0.14.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -2
- package/VERSION +1 -1
- package/commands/build/epic.md +15 -1
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +2 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +37 -2
- package/docs/runtime/protocolo-cliente-runtime.md +74 -12
- package/hooks/build/lib/runtime-client.sh +55 -12
- package/hooks/build/lib/runtime-ops.sh +140 -29
- package/hooks/build/release-ops.sh +13 -8
- package/hooks/build/slice-ops.sh +497 -55
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/tests/test-install.sh +2 -1
- package/scripts/tests/test-runtime-client.sh +49 -3
- package/scripts/tests/test-skill-ops.sh +601 -46
|
@@ -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.
|
|
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
|
|
@@ -198,8 +199,10 @@ El core no menciona ningún dominio de cliente. Toda parametrización entra por
|
|
|
198
199
|
día—. **(3)** `claim` y la propuesta mandan la **versión de grafo** conocida, estampada *al
|
|
199
200
|
despachar* y no al encolar; un `409` por grafo rancio provoca refresco y un reintento en vez de
|
|
200
201
|
un fallo. `legacy` sigue siendo el default y no cambia. Las mitades de servidor de (2) y (3)
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
ya existen en el hub, en el **carril del agente** (`/projects/{id}/agent/epic-proposals`), y la
|
|
203
|
+
**v0.14.2** alineó al cliente con ese contrato: hasta entonces pegaba contra la ruta de consola
|
|
204
|
+
y la propuesta moría con `405`/`422` (#70). Contra un hub que no lo exponga, el cliente degrada a
|
|
205
|
+
«instancia sin soporte». Total: **14 agentes**, **19 hooks**, **13 comandos `/build:*`**,
|
|
203
206
|
**16 skills**.
|
|
204
207
|
- ✅ **v0.13.0 — el grafo llega al hub con aristas** — las dependencias entre épicas viven como
|
|
205
208
|
prosa en `epicas.md` (`**Depende de**: EP-001`) y su lectura estaba delegada al modelo, con un
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.15.0
|
package/commands/build/epic.md
CHANGED
|
@@ -49,6 +49,9 @@ Reúne del repo y de `docs/` lo necesario y **propón** al usuario, vía **AskUs
|
|
|
49
49
|
título, objetivo, capa (`foundational` | `business` | `technical`), alcance de archivos y
|
|
50
50
|
dependencias con épicas existentes. Las historias van con AC en Given/When/Then.
|
|
51
51
|
|
|
52
|
+
El grafo del hub solo distingue **dos** capas (`FOUNDATIONAL` y `BUSINESS`): una épica
|
|
53
|
+
`technical` viaja como `business` y eso es correcto, no un error que debas corregir.
|
|
54
|
+
|
|
52
55
|
No inventes la clasificación de capa ni las dependencias: derívalas del PRD / Story Map y
|
|
53
56
|
confírmalas. Sin aprobación explícita del usuario, **no propongas nada**.
|
|
54
57
|
|
|
@@ -61,10 +64,21 @@ Escribe el borrador en un fichero temporal (nunca por argv):
|
|
|
61
64
|
"layer": "business",
|
|
62
65
|
"files_scope": ["src/…/**"],
|
|
63
66
|
"depends_on": ["EP-012"],
|
|
64
|
-
"stories": [
|
|
67
|
+
"stories": [
|
|
68
|
+
{
|
|
69
|
+
"title": "…",
|
|
70
|
+
"description": "Como … quiero … para …",
|
|
71
|
+
"acceptance_criteria": "Dado … Cuando … Entonces …. Dado … Cuando … Entonces …"
|
|
72
|
+
}
|
|
73
|
+
]
|
|
65
74
|
}
|
|
66
75
|
```
|
|
67
76
|
|
|
77
|
+
Escribe los criterios **completos** en `acceptance_criteria`: `slice-ops.sh` los parte en un
|
|
78
|
+
escenario por entrada y los manda como `acceptance[]`, que es lo que la consola del hub enseña
|
|
79
|
+
en el detalle de la historia (issue #70). Varios escenarios en un mismo campo se separan con
|
|
80
|
+
`. Dado …`; si prefieres darlos ya partidos, usa `"acceptance": ["Dado …", "Dado …"]` y manda.
|
|
81
|
+
|
|
68
82
|
## 2. Proponer
|
|
69
83
|
|
|
70
84
|
```bash
|
|
@@ -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.
|
package/dist/commands/init.js
CHANGED
|
@@ -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 **
|
|
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`
|
|
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
|
|
@@ -228,8 +228,8 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
|
|
|
228
228
|
| `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
|
|
229
229
|
| `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales; reporta además `outbox/rejected/` (conteo + tipo y razón del rechazo más reciente, issue #52) |
|
|
230
230
|
| `slice-ops.sh escalate` | evento `slice_escalated {cause}` (gate y fase dentro del texto de la causa; exige slice activo en runtime) |
|
|
231
|
-
| `slice-ops.sh propose-epic --file B` | `POST /projects/{id}/epic-proposals` (carril directo). Propone una épica **sin `EP-XXX`**: la identidad la asigna el hub al aprobar
|
|
232
|
-
| `slice-ops.sh epic-status [--id P]` | `GET /projects/{id}/epic-proposals/{P}`. Resuelve el ciclo: `QUEUED` → `PROPOSED` → `APPROVED` (
|
|
231
|
+
| `slice-ops.sh propose-epic --file B` | `POST /projects/{id}/agent/epic-proposals` (carril directo). Propone una épica **sin `EP-XXX`**: la identidad la asigna el hub al aprobar. `0` enviada · `5` encolada · `6` rechazada · `3` legacy/dual. Un `epic_code` en el borrador se **ignora**, no se rechaza. El identificador llega en `id` de `EpicProposalOut` |
|
|
232
|
+
| `slice-ops.sh epic-status [--id P]` | `GET /projects/{id}/agent/epic-proposals/{P}`. Resuelve el ciclo: `QUEUED` → `PROPOSED` → `APPROVED` (código en `assigned_code`) / `REJECTED` (motivo en `reject_reason`). Informativo: `rc 0` siempre en runtime |
|
|
233
233
|
| `slice-ops.sh epic-writeback [--id P] [--file F]` | — (local). Escribe en `docs/03-backlog/epicas.md` las épicas ya `APPROVED`, con el código del hub. Mecánico e idempotente: el fichero es proyección del grafo. `--file F` permite otro fichero del backlog (partido en varios), pero **F queda acotado al subárbol `docs/03-backlog/` del proyecto** —`realpath` sobre ambos lados, así que ni symlinks ni `..` escapan— porque los contactos de escritura del arnés en `docs/` son cuatro y acotados (METODOLOGIA §9.2) y este comando promete tocar solo el backlog. Fuera de ahí, `2` con la ruta permitida en el mensaje y **cero escritura**, exista el fichero o no. `0` escribió · `7` nada pendiente · `4` sin fichero · `2` `--file` fuera del carve-out |
|
|
234
234
|
| `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts`; **sin fallback offline** (rc 5 y reintento al reconectar: el agregado `release` no entra por `POST /events`, issue #44) |
|
|
235
235
|
| `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
|
|
@@ -244,27 +244,89 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
|
|
|
244
244
|
"layer": "foundational|business|technical",
|
|
245
245
|
"files_scope": ["…"],
|
|
246
246
|
"depends_on": ["EP-012"],
|
|
247
|
-
"stories": [{"title": "…", "acceptance_criteria": "…"}]
|
|
247
|
+
"stories": [{"title": "…", "description": "…", "acceptance_criteria": "Dado … Cuando … Entonces …"}]
|
|
248
248
|
}
|
|
249
249
|
```
|
|
250
250
|
|
|
251
251
|
`title` y `objective` son obligatorios; `layer` por defecto `business` (rc 2 si no es una de las
|
|
252
252
|
tres). Un `epic_code`/`code`/`id` en el borrador **se ignora con aviso**, no se rechaza la
|
|
253
|
-
propuesta — la identidad la asigna el hub al aprobar
|
|
254
|
-
|
|
253
|
+
propuesta — la identidad la asigna el hub al aprobar.
|
|
254
|
+
|
|
255
|
+
**El borrador NO es el payload** (issue #70). El normalizador traduce al contrato del agente
|
|
256
|
+
(`EpicProposalIn`) antes de encolar, y esa traducción es la única superficie que conoce los dos
|
|
257
|
+
vocabularios:
|
|
258
|
+
|
|
259
|
+
| Borrador (lo que escribe el agente) | Contrato del hub |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `layer: "foundational"` | `layer: "FOUNDATIONAL"` |
|
|
262
|
+
| `layer: "business"` \| `"technical"` | `layer: "BUSINESS"` — el hub no tiene capa técnica; `technical` se **mapea**, no se rechaza |
|
|
263
|
+
| `stories[].acceptance_criteria` (prosa) | `stories[].acceptance[]` — un escenario Dado/Cuando/Entonces por entrada (se parte por líneas y por el `. Dado ` que une dos escenarios). Una `acceptance[]` ya explícita en el borrador manda |
|
|
264
|
+
| `stories[].description` | `stories[].description` (tal cual) |
|
|
265
|
+
|
|
266
|
+
El hub descarta los campos que no reconoce (`extra="ignore"`): mandar `acceptance_criteria`
|
|
267
|
+
**no da error**, simplemente llega una historia sin un solo criterio a la consola.
|
|
255
268
|
|
|
256
269
|
**Códigos de salida** (contrato con la prosa): `0` ok · `2` uso · `3` modo legacy (o claim en dual)
|
|
257
270
|
· `4` sin slice activo · `5` offline (encolado si el tipo tiene camino por la cola; si no, reintento manual al reconectar) · `6` rechazado por el servidor (no reintentar) ·
|
|
258
271
|
`7` sin trabajo.
|
|
259
272
|
|
|
260
273
|
**Modo `dual`:** el fichero es primario y estos comandos **espejan** cada transición a
|
|
261
|
-
`POST /projects/{project_id}/mirror/transitions
|
|
262
|
-
|
|
263
|
-
|
|
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.
|
|
264
325
|
|
|
265
326
|
**Lo que el agente NO hace** (superficies humanas, con PDP): cerrar una release, planificar/abrir/
|
|
266
|
-
drenar/cerrar un front,
|
|
267
|
-
propone y reporta; la persona decide en la consola del
|
|
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.
|
|
268
330
|
|
|
269
331
|
---
|
|
270
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]
|
|
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
|
|
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
|
|
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
|
|
825
|
-
# un 409 por OTRA causa (
|
|
826
|
-
# rancio y tratarlo como tal apartaría la propuesta con un diagnóstico falso.
|
|
827
|
-
|
|
828
|
-
|
|
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
|