@trycore/spec-build-harness 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/INSTALL.md +56 -3
- package/README.md +47 -2
- package/VERSION +1 -1
- package/commands/build/epic.md +15 -1
- package/docs/commands.md +1 -1
- package/docs/getting-started.md +11 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +89 -9
- package/docs/runtime/protocolo-cliente-runtime.md +18 -5
- package/hooks/build/lib/runtime-ops.sh +5 -2
- package/hooks/build/slice-ops.sh +88 -14
- package/package.json +1 -1
- package/scripts/tests/test-skill-ops.sh +85 -19
|
@@ -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.
|
|
5
|
+
"version": "0.14.2",
|
|
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/INSTALL.md
CHANGED
|
@@ -19,6 +19,26 @@ Hay **dos canales** de instalación: el **CLI npm** (canónico, recomendado para
|
|
|
19
19
|
proyecto) y el **plugin nativo** de Claude Code (conveniencia a nivel usuario). Lee el
|
|
20
20
|
[caveat de canales](#5-alternativa-plugin-nativo-con-caveat-de-canales) antes de elegir.
|
|
21
21
|
|
|
22
|
+
## ¿Qué camino me toca?
|
|
23
|
+
|
|
24
|
+
Cuatro situaciones, cuatro caminos. Todos empiezan por §1 (CLI global) y §2 (requisitos).
|
|
25
|
+
|
|
26
|
+
| Tu situación | Cómo saberlo | Qué haces |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **Proyecto nuevo**, sin el arnés | no existe `.claude/.build-harness-version` | §3 `init` → §4 `/build:onboard`. Listo. |
|
|
29
|
+
| **Proyecto existente** con el arnés, quieres ponerlo al día | existe la marca, con una versión vieja | §7 `update`. **No** re-corras `/build:onboard`: tu dominio ya está parametrizado. |
|
|
30
|
+
| **Proyecto existente en `legacy`**, quieres conectarlo al IA Hub | existe la marca y `runtime.mode` es `legacy` | §7 `update` **primero**, luego §9 → [guía runtime §4](docs/runtime/guia-modo-dual-y-migracion.md) |
|
|
31
|
+
| **Proyecto nuevo** que ya nace contra el IA Hub | — | §3 `init` **con las flags de runtime** (§9) → §4 `/build:onboard` |
|
|
32
|
+
|
|
33
|
+
Dos cosas que ahorran disgustos:
|
|
34
|
+
|
|
35
|
+
- **`init` y `update` son el mismo comando.** `init` detecta si ya hay instalación (por
|
|
36
|
+
`.claude/.build-harness-version`) y se comporta como `update`. Re-correrlo es seguro: **nunca**
|
|
37
|
+
pisa `build-state.json` ni `stack-allowlist.json`, que son del consumidor.
|
|
38
|
+
- **Conectar un proyecto al hub exige el arnés al día primero.** Los hooks del cliente runtime no
|
|
39
|
+
existen antes de la 0.9.0: si escribes el modo sobre una instalación vieja, queda escrito y nada
|
|
40
|
+
lo ejecuta.
|
|
41
|
+
|
|
22
42
|
---
|
|
23
43
|
|
|
24
44
|
## 1. Instalar el CLI global (npm)
|
|
@@ -87,7 +107,7 @@ Qué hace `init`:
|
|
|
87
107
|
1. **Verifica requisitos duros** (a menos que uses `--skip-doctor`).
|
|
88
108
|
2. **Siembra los assets** en rutas nativas de Claude Code:
|
|
89
109
|
- `.claude/agents/build/` — 14 agentes.
|
|
90
|
-
- `.claude/commands/opsx/` (10 comandos `/opsx:*`) y `.claude/commands/build/` (
|
|
110
|
+
- `.claude/commands/opsx/` (10 comandos `/opsx:*`) y `.claude/commands/build/` (13 comandos: `/build:onboard`, `/build:reflect`, `/build:architect`, `/build:prototype`, `/build:slice`, `/build:release`, `/build:work`, `/build:resume`, `/build:front`, `/build:claim`, `/build:status`, `/build:escalate`, `/build:epic`). Los cuatro últimos son la **superficie de agente del modo runtime** (§9): en `legacy`, `claim`, `escalate` y `epic` devuelven `rc 3` remitiendo al flujo del fichero, y `status` informa de que la fuente de verdad es `build-state.json`.
|
|
91
111
|
- `.claude/skills/` — 16 skills (`building-a-slice`, `building-a-micro-change`, `releasing-a-version`, `managing-parallel-front`, `setup-architecture`, `prototyping-screens`, `openspec-*`).
|
|
92
112
|
- `.claude/hooks/build/` — 19 hooks (bash + python; 6 son del cliente runtime opt-in — §9).
|
|
93
113
|
3. **Siembra el estado**: `state/build-state.schema.json` y `state/README.md` se versionan;
|
|
@@ -166,9 +186,9 @@ Tras `init`, abre Claude Code en el proyecto y ejecuta:
|
|
|
166
186
|
```
|
|
167
187
|
|
|
168
188
|
`/build:onboard` lee el PRD técnico (y `openspec/project.md` si existe) y, vía `AskUserQuestion`
|
|
169
|
-
(proponiendo un valor "(Recomendado)" por punto), confirma y resuelve **
|
|
189
|
+
(proponiendo un valor "(Recomendado)" por punto), confirma y resuelve **7 puntos de extensión**
|
|
170
190
|
que leen los agentes de calidad (`security-reviewer`, `stack-guardian`, `data-consistency-checker`,
|
|
171
|
-
`ux-krug-reviewer`, `simple-design-reviewer`):
|
|
191
|
+
`ux-krug-reviewer`, `simple-design-reviewer`, `ux-fidelity-reviewer`):
|
|
172
192
|
|
|
173
193
|
| Placeholder | Qué pregunta |
|
|
174
194
|
|---|---|
|
|
@@ -178,6 +198,7 @@ que leen los agentes de calidad (`security-reviewer`, `stack-guardian`, `data-co
|
|
|
178
198
|
| `{{SENSITIVE_DATA_CATEGORIES}}` | Categorías de datos sensibles / PII reguladas |
|
|
179
199
|
| `{{SERVER_SIDE_SECRETS}}` | Secretos server-side que jamás van al cliente |
|
|
180
200
|
| `{{HIGH_STAKES_DECISIONS}}` | Decisiones de alto impacto que exigen explicabilidad en la UI |
|
|
201
|
+
| `{{DESIGN_SOURCE}}` | Fuente de diseño / referencia visual (prototipo, export), o `N/A` si el proyecto no tiene UI |
|
|
181
202
|
|
|
182
203
|
Qué resuelve:
|
|
183
204
|
|
|
@@ -308,12 +329,44 @@ modo `legacy` (default, sin cambios). Requiere un runtime corriendo y un **token
|
|
|
308
329
|
emitido por un ADMIN en la consola del hub.
|
|
309
330
|
|
|
310
331
|
```bash
|
|
332
|
+
# Proyecto nuevo contra el hub, o proyecto existente YA actualizado (ver la tabla del principio)
|
|
311
333
|
trycore-build init --runtime-url "https://tu-runtime.example.com" --runtime-token "<token>"
|
|
334
|
+
|
|
312
335
|
trycore-build doctor # sección "Runtime": token, conectividad, lock, cola offline
|
|
313
336
|
trycore-build status # sección "Runtime": conexión, proyección, contexto sincronizado
|
|
314
337
|
trycore-build migrate --project-ref "<nombre-en-el-hub>" # bundle de estado histórico para un ADMIN
|
|
315
338
|
```
|
|
316
339
|
|
|
340
|
+
Sin `--runtime-mode`, el default con URL+token es **`dual`** (nunca `runtime`): el fichero local
|
|
341
|
+
sigue mandando y cada transición se espeja al servidor para poder compararlas. El corte directo a
|
|
342
|
+
`runtime` no está soportado por diseño.
|
|
343
|
+
|
|
344
|
+
### Qué gana el proyecto al conectarse
|
|
345
|
+
|
|
346
|
+
| | Qué hace | Necesita el hub |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| **Contexto vivo** | El daemon refresca `/agent/context` cada `runtime.context_refresh_s` segundos (default 45, `0` desactiva) — una terminal abierta se entera de lo que pasa sin reiniciar | **No**, funciona hoy |
|
|
349
|
+
| **Épicas propuestas** | `/build:epic` propone la épica, el hub asigna el `EP-XXX` al aprobarla y `/build:epic --check` la proyecta a `epicas.md` | Sí (`trycore-ia-hub#113`/`#114`) |
|
|
350
|
+
| **Versión de grafo** | `claim` y la propuesta mandan la versión conocida; un `409` provoca refresco y reintento en vez de fallo | Sí (`trycore-ia-hub#115`) |
|
|
351
|
+
| **Reparto de trabajo** | `/build:claim` pide la siguiente tarea por lease; `/build:status` informa; `/build:escalate` registra un bloqueo | Sí |
|
|
352
|
+
|
|
353
|
+
Contra un hub que no implementa una de esas mitades, el cliente **degrada limpiamente**: un `404`
|
|
354
|
+
se aparta diciendo «esta instancia está sin soporte», sin reintentos en bucle, y un hub que no
|
|
355
|
+
versiona el grafo ve el mismo cuerpo de `claim` de siempre.
|
|
356
|
+
|
|
357
|
+
### Configuración
|
|
358
|
+
|
|
359
|
+
`.claude/config/build-config.json`, bloque `runtime`:
|
|
360
|
+
|
|
361
|
+
| Clave | Default | Para qué |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| `mode` | `legacy` | `legacy` \| `dual` \| `runtime`. Un valor inválido cae a `legacy`, nunca a la red. |
|
|
364
|
+
| `stale_seconds` | `900` | A partir de cuántos segundos la proyección se considera stale |
|
|
365
|
+
| `context_refresh_s` | `45` | Cadencia del refresco de contexto del daemon; `0` lo desactiva |
|
|
366
|
+
|
|
367
|
+
Overrides por entorno, útiles para una sesión puntual sin editar el fichero:
|
|
368
|
+
`TRYCORE_RUNTIME_MODE`, `TRYCORE_CONTEXT_REFRESH_S`.
|
|
369
|
+
|
|
317
370
|
Guía completa (los tres modos, cómo activar/verificar/volver a legacy, cuándo se corta) →
|
|
318
371
|
[`docs/runtime/guia-modo-dual-y-migracion.md`](docs/runtime/guia-modo-dual-y-migracion.md).
|
|
319
372
|
|
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ trycore-spec-build-harness/
|
|
|
143
143
|
├── agents/build/ ← 14 agentes revisores (segunda opinión, contexto limpio)
|
|
144
144
|
├── commands/
|
|
145
145
|
│ ├── opsx/ ← 10 comandos /opsx:* (ciclo OpenSpec)
|
|
146
|
-
│ └── build/ ←
|
|
146
|
+
│ └── build/ ← 13 comandos /build:* (onboard, reflect, architect, prototype, slice, release, work, resume, front, claim, status, escalate, epic)
|
|
147
147
|
├── skills/ ← 16 skills (building-a-slice, building-a-micro-change, releasing-a-version, managing-parallel-front, setup-architecture, prototyping-screens, 10 openspec-*) + 3 plantillas *.workflow.js (opt-in, read-only)
|
|
148
148
|
├── hooks/build/ ← 19 hooks (gate-check, reflect-nudge, release-gate-nudge, scaffold-guard, gitflow-guard, stack-guard, statusline-bridge, context-monitor, reconcile-build-state, …) + 6 opt-in del cliente runtime (session-start, event-emitter, context-sync, heartbeat, dual-compare, session-stop — ver docs/hooks.md)
|
|
149
149
|
├── state/ ← máquina de estado legacy: build-state.json + schema + README
|
|
@@ -183,7 +183,52 @@ El core no menciona ningún dominio de cliente. Toda parametrización entra por
|
|
|
183
183
|
|
|
184
184
|
## Roadmap
|
|
185
185
|
|
|
186
|
-
- ✅ **v0.
|
|
186
|
+
- ✅ **v0.14.0 (actual) — contexto vivo, épicas propuestas y versión de grafo** (#61, #62, #63) —
|
|
187
|
+
tres capacidades del cliente del runtime que componen entre sí. **(1)** El daemon de heartbeat
|
|
188
|
+
refresca `GET /agent/context` con **cadencia propia** (`runtime.context_refresh_s`, default 45 s,
|
|
189
|
+
`0` la desactiva): antes la caché se hidrataba al arrancar la sesión y en cada `claim` —y el
|
|
190
|
+
`claim` ocurre una vez por slice—, así que una terminal abierta podía trabajar horas contra una
|
|
191
|
+
foto vieja; un refresco fallido **conserva** la caché anterior y la marca stale, porque quedarse
|
|
192
|
+
sin proyección dejaría al agente huérfano. **(2)** En modo `runtime` la épica se **propone** al
|
|
193
|
+
hub en vez de escribirse en el backlog: comando nuevo **`/build:epic`**, subcomandos
|
|
194
|
+
`propose-epic`/`epic-status`/`epic-writeback` y un **carril directo** en la cola offline que
|
|
195
|
+
sobrevive a quedarse sin red; el hub asigna el `EP-XXX` al aprobar y `docs/03-backlog/epicas.md`
|
|
196
|
+
pasa a ser **proyección del grafo**, nunca una fuente paralela —el agente pierde la potestad de
|
|
197
|
+
inventar identidades, que es lo que hacía colisionar las historias de dos épicas creadas el mismo
|
|
198
|
+
día—. **(3)** `claim` y la propuesta mandan la **versión de grafo** conocida, estampada *al
|
|
199
|
+
despachar* y no al encolar; un `409` por grafo rancio provoca refresco y un reintento en vez de
|
|
200
|
+
un fallo. `legacy` sigue siendo el default y no cambia. Las mitades de servidor de (2) y (3)
|
|
201
|
+
ya existen en el hub, en el **carril del agente** (`/projects/{id}/agent/epic-proposals`), y la
|
|
202
|
+
**v0.14.2** alineó al cliente con ese contrato: hasta entonces pegaba contra la ruta de consola
|
|
203
|
+
y la propuesta moría con `405`/`422` (#70). Contra un hub que no lo exponga, el cliente degrada a
|
|
204
|
+
«instancia sin soporte». Total: **14 agentes**, **19 hooks**, **13 comandos `/build:*`**,
|
|
205
|
+
**16 skills**.
|
|
206
|
+
- ✅ **v0.13.0 — el grafo llega al hub con aristas** — las dependencias entre épicas viven como
|
|
207
|
+
prosa en `epicas.md` (`**Depende de**: EP-001`) y su lectura estaba delegada al modelo, con un
|
|
208
|
+
ejemplo en `/build:onboard` que mostraba `"depends_on": []`: las 41 épicas del piloto entraron
|
|
209
|
+
sin una sola arista y nadie lo detectó, porque un grafo vacío no falla, solo empobrece (#56).
|
|
210
|
+
`graph-bundle.py` gana un **extractor determinista** (`--from-docs` / `--print-deps`) que relee
|
|
211
|
+
el campo, con avisos de cobertura y traza de la fuente en `depends_on_source`. Además,
|
|
212
|
+
`unmapped[].original` viaja siempre como dict (#55): el hub valida el bundle entero con pydantic,
|
|
213
|
+
así que un escalar lo rechazaba **completo** antes de persistir nada.
|
|
214
|
+
- ✅ **v0.12.0 — la fase del slice llega al hub** (#52) — el hub modela la fase con eventos
|
|
215
|
+
`phase_advanced` de orden estricto y exige `phase == pr` para archivar, pero el cliente no emitía
|
|
216
|
+
ninguna: los slices quedaban «en construcción» con su PR ya mergeado. Subcomando
|
|
217
|
+
`slice-ops.sh phase <to>`, que emite la cadena que falte (idempotente, sin saltos), y **catch-up
|
|
218
|
+
automático** al archivar. Además, `outbox/rejected/` deja de ser invisible: la razón del rechazo
|
|
219
|
+
se persiste en el propio fichero y la muestran `slice-ops.sh status` y `trycore-build status`/`doctor`.
|
|
220
|
+
- ✅ **v0.11.1 — auto-cierre de releases en verde** — el cierre manual de una release con 6/6 gates
|
|
221
|
+
PASS era control-teatro: el hub la cierra solo (`closed_by: AUTO_6_OF_6_PASS`) y los casos
|
|
222
|
+
degradados siguen siendo decisión humana. Arreglado también que `release-ops.sh verdict` devolvía
|
|
223
|
+
**422 en todo reporte** (faltaba el campo `verdict` en mayúsculas junto a `status`).
|
|
224
|
+
- ✅ **v0.11.0 — drift cliente↔hub del primer piloto real** (#36–#44) — la primera migración contra
|
|
225
|
+
el hub en producción reveló un desajuste sistemático entre lo que el cliente emite y lo que el
|
|
226
|
+
hub valida: `claim` devolvía 422 en todo intento, la cola offline **nunca drenaba**, `fact`
|
|
227
|
+
emitía tipos fuera del catálogo, `claim --epic` se ignoraba en silencio y repartía otra épica, y
|
|
228
|
+
el import rechazaba 36 de 41 entradas del bundle histórico. Cada fix verificado contra el código
|
|
229
|
+
real del hub, con regresión anti-drift nueva que valida todo tipo encolable contra una réplica
|
|
230
|
+
literal de su catálogo.
|
|
231
|
+
- ✅ **v0.10.0 — convergencia del inner loop** — ataque a los seis multiplicadores de
|
|
187
232
|
latencia de la verificación adversarial (iniciativa #31, issues #25–#30) **sin bajar el rigor**:
|
|
188
233
|
**re-verificación incremental** con `verified_at_sha` por item de `wiring_checklist[]` (las
|
|
189
234
|
pasadas 2+ re-ejecutan O(items tocados), no O(items totales)); **condición de parada** del bucle
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.14.
|
|
1
|
+
0.14.2
|
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
|
package/docs/commands.md
CHANGED
|
@@ -136,7 +136,7 @@ protocolo del fichero (`/build:slice`) — no rompen el flujo normal si el proye
|
|
|
136
136
|
|
|
137
137
|
| Slash command | Propósito |
|
|
138
138
|
|---|---|
|
|
139
|
-
| `/build:claim
|
|
139
|
+
| `/build:claim` | Pide la siguiente tarea al runtime (`POST /tasks/next`): sincroniza contexto, reporta hashes locales (detección de drift) y reclama por lease. Si trae `CHECKPOINT`, continúa desde ahí — **nunca reinicia** un slice de otro agente. **No acepta épica dirigida**: el hub reparte por orden de cola y `claim --epic` falla explícito con `rc 2` (issue #39 — el servidor ignoraba el `epic_code` y entregaba otra épica como si fuera la pedida, dejando un lease huérfano). |
|
|
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. |
|
package/docs/getting-started.md
CHANGED
|
@@ -24,6 +24,14 @@ trycore-build doctor # verifica requisitos y hooks
|
|
|
24
24
|
|
|
25
25
|
Eso es el ciclo completo. Lo de abajo explica cada paso.
|
|
26
26
|
|
|
27
|
+
> **¿Conectado a un IA Hub?** El pipeline es **el mismo**; cambia dónde vive el estado y quién
|
|
28
|
+
> valida las transiciones. Añades cuatro comandos a la caja de herramientas — `/build:claim` (pide
|
|
29
|
+
> trabajo), `/build:status` (informe), `/build:epic` (propone una épica) y `/build:escalate`
|
|
30
|
+
> (registra un bloqueo) — y el arranque lleva las flags de runtime. En `legacy` no estorban:
|
|
31
|
+
> `claim`, `epic` y `escalate` devuelven `rc 3` y te remiten al flujo del fichero, y `status`
|
|
32
|
+
> informa de que la fuente de verdad es `build-state.json`.
|
|
33
|
+
> Instalación y activación → [`docs/runtime/guia-modo-dual-y-migracion.md`](runtime/guia-modo-dual-y-migracion.md).
|
|
34
|
+
|
|
27
35
|
---
|
|
28
36
|
|
|
29
37
|
## Requisitos duros
|
|
@@ -48,7 +56,9 @@ npm i -g @fission-ai/openspec @trycore/spec-build-harness
|
|
|
48
56
|
|
|
49
57
|
## 2 · `trycore-build init` (terminal)
|
|
50
58
|
|
|
51
|
-
Desde la raíz de tu proyecto. Es **idempotente** (re-correrlo es seguro) y siembra: **14 agentes**, **16 skills** (`building-a-slice`, `building-a-micro-change`, `releasing-a-version`, `managing-parallel-front`, `setup-architecture`, `prototyping-screens` + 10 `openspec-*`), **10 comandos `/opsx:*`** + **
|
|
59
|
+
Desde la raíz de tu proyecto. Es **idempotente** (re-correrlo es seguro) y siembra: **14 agentes**, **16 skills** (`building-a-slice`, `building-a-micro-change`, `releasing-a-version`, `managing-parallel-front`, `setup-architecture`, `prototyping-screens` + 10 `openspec-*`), **10 comandos `/opsx:*`** + **13 comandos `/build:*`** (`onboard`, `reflect`, `architect`, `prototype`, `slice`, `release`, `work`, `resume`, `front` + `claim`, `status`, `escalate`, `epic`, que son la superficie del modo runtime), **19 hooks**, el estado vacío `.claude/state/build-state.json` (gitignored, nunca se sobreescribe) y el bloque `<!-- BEGIN trycore-build-harness -->` en tu `CLAUDE.md` con `{{placeholders}}` sin resolver.
|
|
60
|
+
|
|
61
|
+
> **¿Ya tenías el arnés instalado?** Entonces esto es un `update`, no un `init` — el CLI lo detecta solo por `.claude/.build-harness-version`. Corre `npm i -g @trycore/spec-build-harness@latest` y luego `trycore-build update`; **no** repitas `/build:onboard`, tu dominio ya está parametrizado. La tabla «¿Qué camino me toca?» de [`INSTALL.md`](../INSTALL.md) cubre los cuatro casos.
|
|
52
62
|
|
|
53
63
|
```bash
|
|
54
64
|
trycore-build init
|
|
@@ -55,16 +55,47 @@ Si `--runtime-mode` no se especifica pero sí `--runtime-url`/`--runtime-token`,
|
|
|
55
55
|
`dual` (nunca `runtime` — el corte directo a `runtime` sin pasar por `dual` no está soportado por
|
|
56
56
|
diseño: es exactamente el "big bang" que el plan de migración evita).
|
|
57
57
|
|
|
58
|
-
## 4. Activar `dual` en un proyecto ya instalado
|
|
58
|
+
## 4. Activar `dual` en un proyecto ya instalado
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
Este es el camino normal: el proyecto ya lleva el arnés en `legacy` (tiene
|
|
61
|
+
`.claude/.build-harness-version` y su `build-state.json` con historial) y quieres que además
|
|
62
|
+
espeje al hub.
|
|
63
|
+
|
|
64
|
+
**`trycore-build update` no acepta las flags de runtime.** Vuelve a correr `init` con ellas sobre
|
|
65
|
+
el proyecto existente: `init` detecta la instalación previa y es **idempotente** — no pisa
|
|
66
|
+
`build-state.json` ni `stack-allowlist.json`, solo añade las credenciales y el modo.
|
|
63
67
|
|
|
64
68
|
```bash
|
|
65
|
-
|
|
69
|
+
# 0. Parte de un arnés al día (si vienes de una versión vieja, primero esto)
|
|
70
|
+
npm i -g @trycore/spec-build-harness@latest
|
|
71
|
+
cd /ruta/al/proyecto
|
|
72
|
+
trycore-build update
|
|
73
|
+
|
|
74
|
+
# 1. Añade las credenciales y activa el espejo
|
|
75
|
+
trycore-build init --runtime-url "https://tu-runtime.example.com" \
|
|
76
|
+
--runtime-token "<token-del-ADMIN>" \
|
|
77
|
+
--runtime-mode dual
|
|
78
|
+
|
|
79
|
+
# 2. Comprueba
|
|
80
|
+
trycore-build doctor
|
|
81
|
+
trycore-build status
|
|
66
82
|
```
|
|
67
83
|
|
|
84
|
+
El paso 0 importa: las credenciales y el modo se escriben igual, pero los **hooks del cliente
|
|
85
|
+
runtime** (`session-start`, `event-emitter`, `context-sync`, `heartbeat`, `dual-compare`,
|
|
86
|
+
`session-stop`) solo existen desde la 0.9.0. Si el proyecto arrastra una versión anterior, el modo
|
|
87
|
+
queda escrito y **nada lo ejecuta**: sin ese `update` previo el espejo no espeja nada.
|
|
88
|
+
|
|
89
|
+
> **Salto grande de versión.** `update` refresca assets y schema, nunca tu estado. Si vienes de
|
|
90
|
+
> varias minor por detrás, corre `trycore-build status` después y mira el informe en vez de darlo
|
|
91
|
+
> por bueno: te dirá si la versión instalada quedó sincronizada y si el estado sigue validando.
|
|
92
|
+
|
|
93
|
+
### ¿Y el historial que ya tienes?
|
|
94
|
+
|
|
95
|
+
Activar `dual` **no** sube tus slices archivados: el espejo solo cubre lo que ocurra de aquí en
|
|
96
|
+
adelante. Para que el hub conozca el pasado, ver §7 (`trycore-build migrate`). Es opcional — el
|
|
97
|
+
piloto funciona igual sin ello, solo que las proyecciones del servidor empiezan vacías.
|
|
98
|
+
|
|
68
99
|
## 5. Verificar que quedó bien
|
|
69
100
|
|
|
70
101
|
```bash
|
|
@@ -73,19 +104,68 @@ trycore-build status # resumen + sección "Runtime": conexión, proyección,
|
|
|
73
104
|
```
|
|
74
105
|
|
|
75
106
|
Dentro de una sesión de Claude, `/build:status` da el mismo informe (más `next-step` derivado) y
|
|
76
|
-
`/build:claim
|
|
107
|
+
`/build:claim` reclama la siguiente tarea directamente contra el runtime. **`claim` no acepta épica
|
|
108
|
+
dirigida**: el hub reparte por orden de cola y `claim --epic EP-XXX` falla explícito con `rc 2`
|
|
109
|
+
(issue #39 — el servidor ignoraba el `epic_code` en el cuerpo y entregaba la primera épica de su
|
|
110
|
+
cola como si fuera la pedida, dejando un lease huérfano que solo la consola admin libera). Si
|
|
111
|
+
necesitas dirigir una épica concreta, termina el slice en `dual`, pide al ADMIN apagar el espejo y
|
|
112
|
+
reclama lo que la cola sirva.
|
|
77
113
|
|
|
78
114
|
## 6. Qué cambia en el día a día
|
|
79
115
|
|
|
80
116
|
**Nada que tengas que operar tú a mano.** Las skills (`building-a-slice`, `releasing-a-version`,
|
|
81
117
|
`managing-parallel-front`) conducen exactamente el mismo pipeline; por debajo, en vez de
|
|
82
|
-
leer/escribir `build-state.json` directamente, llaman a `slice-ops.sh`/`release-ops.sh`
|
|
83
|
-
subcomandos: `claim`, `gate`, `wiring`, `progress`, `checkpoint`, `submit`, `archive`, `fact`,
|
|
84
|
-
`propose-asset`, `status`, `escalate`, y del lado release `verdict`/`front-integration`) — nunca
|
|
118
|
+
leer/escribir `build-state.json` directamente, llaman a `slice-ops.sh`/`release-ops.sh` — nunca
|
|
85
119
|
arman peticiones a mano. En `dual`, cada transición también se **espeja** al servidor con la
|
|
86
120
|
identidad del fichero (que sigue mandando). Si el espejo falla (red caída, servidor rechaza), el
|
|
87
121
|
trabajo local **nunca se bloquea** — se avisa y queda como discrepancia para el comparador.
|
|
88
122
|
|
|
123
|
+
`slice-ops.sh` tiene **17 subcomandos** (los conducen las skills; los listamos para que puedas
|
|
124
|
+
leer un log o depurar, no para que los teclees):
|
|
125
|
+
|
|
126
|
+
| Subcomando | Para qué |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `mode` | imprime `legacy`\|`dual`\|`runtime` |
|
|
129
|
+
| `claim` | reclama trabajo (el hub reparte por orden de cola; sin épica dirigida) |
|
|
130
|
+
| `next-step` | deriva la siguiente acción desde la caché de proyección |
|
|
131
|
+
| `phase` | reporta la fase del pipeline (emite la cadena que falte; idempotente) |
|
|
132
|
+
| `gate` | reporta un veredicto del slice |
|
|
133
|
+
| `submit` · `archive` | entrega y archiva el slice |
|
|
134
|
+
| `wiring` | siembra y actualiza el checklist de cableado |
|
|
135
|
+
| `progress` · `checkpoint` | bitácora y continuidad tras una caída |
|
|
136
|
+
| `fact` | reporta un hecho de proyecto confirmado |
|
|
137
|
+
| `propose-asset` | propone un documento al plano de contexto (lo publica un ADMIN) |
|
|
138
|
+
| `propose-epic` · `epic-status` · `epic-writeback` | proponen una épica, consultan su estado y proyectan la aprobada a `epicas.md` |
|
|
139
|
+
| `status` · `escalate` | informe local del agente y registro de un bloqueo |
|
|
140
|
+
|
|
141
|
+
Del lado release, `release-ops.sh` añade `verdict` y `front-integration`.
|
|
142
|
+
|
|
143
|
+
**Códigos de salida** (sobre estos ramifica la prosa de las skills): `0` ok · `2` uso · `3` legacy ·
|
|
144
|
+
`4` sin slice · `5` offline · `6` rechazado · `7` sin trabajo.
|
|
145
|
+
|
|
146
|
+
### Lo que aportó la 0.14.0
|
|
147
|
+
|
|
148
|
+
- **El contexto ya no se queda congelado** (#61). El daemon de heartbeat refresca `GET /agent/context`
|
|
149
|
+
cada `runtime.context_refresh_s` segundos (default **45**; `0` lo desactiva; override
|
|
150
|
+
`TRYCORE_CONTEXT_REFRESH_S`), **solo en modo `runtime`**. Antes, la caché se hidrataba al arrancar
|
|
151
|
+
la sesión y en cada `claim` — y el `claim` ocurre una vez por slice, así que una terminal abierta
|
|
152
|
+
podía trabajar horas contra una foto vieja. Si el refresco falla, la caché anterior **se conserva**
|
|
153
|
+
y se marca stale (lo dice `/build:status`): quedarse sin proyección dejaría al agente huérfano.
|
|
154
|
+
- **Las épicas se proponen, no se escriben** (#62). En `runtime`, el agente ya no elige el `EP-XXX`:
|
|
155
|
+
lo propone al hub con **`/build:epic`**, un humano lo aprueba en la consola, el hub asigna la
|
|
156
|
+
identidad y `/build:epic --check` proyecta la épica aprobada a `docs/03-backlog/epicas.md`. El
|
|
157
|
+
fichero pasa a ser una **proyección del grafo**, nunca una fuente paralela. La propuesta viaja por
|
|
158
|
+
un carril de la cola offline, así que sobrevive a quedarte sin red.
|
|
159
|
+
- **Una terminal desactualizada ya no puede hacer daño** (#63). `claim` y la propuesta de épica
|
|
160
|
+
mandan la versión de grafo que conocen; si está rancia, el hub responde `409` y el cliente
|
|
161
|
+
refresca y reintenta una vez en vez de fallar. `/build:status` avisa si tu versión va por detrás.
|
|
162
|
+
|
|
163
|
+
> **Dependencia externa:** las mitades de servidor de #62 y #63 (`trycore-ia-hub#113`, `#114`, `#115`)
|
|
164
|
+
> **todavía no existen**. Contra un hub que no las implementa, `/build:epic` recibe un `404` y aparta
|
|
165
|
+
> la propuesta diciendo «esta instancia está sin soporte», sin reintentos en bucle; y un hub que no
|
|
166
|
+
> versiona el grafo ve exactamente el mismo cuerpo de `claim` de siempre. El refresco de contexto
|
|
167
|
+
> (#61) **sí funciona hoy**: no necesita nada nuevo del servidor.
|
|
168
|
+
|
|
89
169
|
**El comparador dual** (`hooks/build/dual-compare.sh`, hook `Stop`) corre después de cada turno:
|
|
90
170
|
compara la proyección del servidor contra el fichero local (épica, fase, gates ya resueltos) y, si
|
|
91
171
|
divergen, lo escala vía `/build:escalate` — nunca bloquea el cierre de sesión. Es el instrumento
|
|
@@ -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,14 +244,27 @@ 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) ·
|
|
@@ -112,8 +112,11 @@ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
|
|
|
112
112
|
# (`EP-XXX`) al aprobar y un humano aprueba: por eso hay una ruta de creación y otra de
|
|
113
113
|
# consulta, y ninguna de publicación. Mismo patrón que las propuestas de asset
|
|
114
114
|
# (`ops_path_proposals`), que es su precedente en este cliente.
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
# [#70] El carril del AGENTE lleva `/agent/`: `/projects/{id}/epic-proposals` (sin él) es la
|
|
116
|
+
# vista de CONSOLA — GET con JWT de usuario — y responde `405 Allow: GET` a un POST con token
|
|
117
|
+
# de proyecto. Las dos superficies no se comparten por diseño (hub CON-1).
|
|
118
|
+
ops_path_epic_proposals() { echo "/projects/$1/agent/epic-proposals"; }
|
|
119
|
+
ops_path_epic_proposal() { echo "/projects/$1/agent/epic-proposals/$2"; }
|
|
117
120
|
|
|
118
121
|
ops_mode() { runtime_mode; }
|
|
119
122
|
|
package/hooks/build/slice-ops.sh
CHANGED
|
@@ -485,8 +485,29 @@ _epic_draft_normalize() {
|
|
|
485
485
|
# el motivo del rc 2 (validación) o el aviso de `epic_code` ignorado; silenciarlo aquí
|
|
486
486
|
# los perdería a los dos.
|
|
487
487
|
python3 - "$f" <<'PY'
|
|
488
|
-
import json,sys
|
|
489
|
-
|
|
488
|
+
import json,re,sys
|
|
489
|
+
# [#70] El vocabulario del BORRADOR (lo que escribe el agente) y el del CONTRATO del hub
|
|
490
|
+
# (`EpicProposalIn`) no son el mismo: el borrador habla en minúsculas y conoce `technical`;
|
|
491
|
+
# el hub solo acepta el literal FOUNDATIONAL|BUSINESS. La traducción vive aquí, en el borde,
|
|
492
|
+
# porque rechazar `technical` sería castigar al agente por una distinción que el grafo no
|
|
493
|
+
# hace: para el hub una épica técnica es de negocio.
|
|
494
|
+
CAPAS={"foundational":"FOUNDATIONAL","business":"BUSINESS","technical":"BUSINESS"}
|
|
495
|
+
|
|
496
|
+
def escenarios(txt):
|
|
497
|
+
"""Criterios en prosa -> `acceptance[]` del contrato: un escenario Dado/Cuando/Entonces
|
|
498
|
+
por entrada. Se parte por líneas y, dentro de cada una, por el `. Dado ` que separa dos
|
|
499
|
+
escenarios seguidos en un mismo párrafo."""
|
|
500
|
+
out=[]
|
|
501
|
+
for linea in str(txt).splitlines():
|
|
502
|
+
linea=linea.strip().lstrip("-*•").strip()
|
|
503
|
+
if not linea:
|
|
504
|
+
continue
|
|
505
|
+
for trozo in re.split(r"\.\s+(?=Dado\s)", linea):
|
|
506
|
+
trozo=trozo.strip()
|
|
507
|
+
if trozo:
|
|
508
|
+
out.append(trozo[:2000])
|
|
509
|
+
return out[:20]
|
|
510
|
+
|
|
490
511
|
try:
|
|
491
512
|
d=json.load(open(sys.argv[1]))
|
|
492
513
|
except Exception:
|
|
@@ -499,29 +520,77 @@ if not title:
|
|
|
499
520
|
print("⛔ el borrador no trae `title` (título de la épica)", file=sys.stderr); raise SystemExit(2)
|
|
500
521
|
if not objective:
|
|
501
522
|
print("⛔ el borrador no trae `objective` (objetivo de la épica)", file=sys.stderr); raise SystemExit(2)
|
|
502
|
-
layer=d.get("layer") or "business"
|
|
523
|
+
layer=str(d.get("layer") or "business").strip().lower()
|
|
503
524
|
if layer not in CAPAS:
|
|
504
525
|
print("⛔ `layer` debe ser uno de: %s" % ", ".join(sorted(CAPAS)), file=sys.stderr); raise SystemExit(2)
|
|
505
526
|
ignorados=[k for k in ("epic_code","code","id") if d.get(k)]
|
|
506
527
|
stories=[]
|
|
507
528
|
for s in d.get("stories") or []:
|
|
508
529
|
if isinstance(s,str):
|
|
509
|
-
stories.append({"title":s})
|
|
530
|
+
stories.append({"title":s[:300]})
|
|
510
531
|
elif isinstance(s,dict) and (s.get("title") or "").strip():
|
|
511
|
-
st={"title":s["title"].strip()}
|
|
512
|
-
if s.get("
|
|
513
|
-
st["
|
|
532
|
+
st={"title":s["title"].strip()[:300]}
|
|
533
|
+
if s.get("description"):
|
|
534
|
+
st["description"]=str(s["description"])[:2000]
|
|
535
|
+
# La `acceptance` explícita del borrador manda; si no, se derivan de los criterios en
|
|
536
|
+
# prosa. `acceptance_criteria` NO viaja: el hub descarta el campo en el borde
|
|
537
|
+
# (`extra="ignore"`) y la consola enseñaba las historias sin un solo criterio.
|
|
538
|
+
acc=s.get("acceptance")
|
|
539
|
+
if isinstance(acc,list):
|
|
540
|
+
acc=[str(x).strip()[:2000] for x in acc if str(x).strip()][:20]
|
|
541
|
+
elif s.get("acceptance_criteria"):
|
|
542
|
+
acc=escenarios(s["acceptance_criteria"])
|
|
543
|
+
else:
|
|
544
|
+
acc=[]
|
|
545
|
+
if acc:
|
|
546
|
+
st["acceptance"]=acc
|
|
514
547
|
stories.append(st)
|
|
515
|
-
out={"title":title[:300],"objective":objective[:2000],"layer":layer,
|
|
548
|
+
out={"title":title[:300],"objective":objective[:2000],"layer":CAPAS[layer],
|
|
516
549
|
"files_scope":[str(x) for x in (d.get("files_scope") or []) if isinstance(x,(str,int))],
|
|
517
550
|
"depends_on":[str(x) for x in (d.get("depends_on") or []) if isinstance(x,(str,int))],
|
|
518
|
-
"stories":stories
|
|
551
|
+
"stories":stories}
|
|
519
552
|
if ignorados:
|
|
520
553
|
print("IGNORADOS %s" % ",".join(ignorados), file=sys.stderr)
|
|
521
554
|
print(json.dumps(out,ensure_ascii=False))
|
|
522
555
|
PY
|
|
523
556
|
}
|
|
524
557
|
|
|
558
|
+
# [#70] `EpicProposalOut` del contrato nombra `id` al identificador de la propuesta (el
|
|
559
|
+
# borrador de hub#113 lo llamaba `proposal_id`). Sin esto la terminal decía «sin identificador
|
|
560
|
+
# en la respuesta» y el ledger guardaba "" — `epic-status` se quedaba sin a quién preguntar.
|
|
561
|
+
# Se acepta el nombre viejo como respaldo: un hub anterior al contrato sigue funcionando.
|
|
562
|
+
_epic_proposal_id() {
|
|
563
|
+
command -v python3 >/dev/null 2>&1 || { echo ""; return; }
|
|
564
|
+
printf '%s' "$1" | python3 -c '
|
|
565
|
+
import json,sys
|
|
566
|
+
try:
|
|
567
|
+
d=json.load(sys.stdin)
|
|
568
|
+
r=(d.get("response") if isinstance(d.get("response"),dict) else d) or {}
|
|
569
|
+
print(r.get("id") or r.get("proposal_id") or "")
|
|
570
|
+
except Exception:
|
|
571
|
+
print("")
|
|
572
|
+
' 2>/dev/null
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
# _epic_from_response <respuesta> — la épica aprobada como objeto para el ledger. El contrato
|
|
576
|
+
# la devuelve en la RAÍZ de `EpicProposalOut`; el borrador viejo la anidaba en `epic`.
|
|
577
|
+
_epic_from_response() {
|
|
578
|
+
command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
|
|
579
|
+
printf '%s' "$1" | python3 -c '
|
|
580
|
+
import json,sys
|
|
581
|
+
try:
|
|
582
|
+
d=json.load(sys.stdin)
|
|
583
|
+
except Exception:
|
|
584
|
+
print("{}"); raise SystemExit(0)
|
|
585
|
+
if not isinstance(d,dict):
|
|
586
|
+
print("{}"); raise SystemExit(0)
|
|
587
|
+
e=d.get("epic")
|
|
588
|
+
if not isinstance(e,dict):
|
|
589
|
+
e={k:d[k] for k in ("title","objective","layer","stories","files_scope","depends_on") if k in d}
|
|
590
|
+
print(json.dumps(e,ensure_ascii=False))
|
|
591
|
+
' 2>/dev/null || echo "{}"
|
|
592
|
+
}
|
|
593
|
+
|
|
525
594
|
cmd_propose_epic() {
|
|
526
595
|
local file="" mode pid norm errf rc cid pfile ack proposal_id reason
|
|
527
596
|
while [ $# -gt 0 ]; do
|
|
@@ -590,7 +659,7 @@ cmd_propose_epic() {
|
|
|
590
659
|
runtime_dispatch_outbox >/dev/null 2>&1
|
|
591
660
|
ack="$(ops_epic_ack_read "$cid")"
|
|
592
661
|
if [ -n "$ack" ]; then
|
|
593
|
-
proposal_id="$(
|
|
662
|
+
proposal_id="$(_epic_proposal_id "$ack")"
|
|
594
663
|
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$proposal_id")}"
|
|
595
664
|
echo "propuesta enviada: ${proposal_id:-sin identificador en la respuesta}"
|
|
596
665
|
echo "Queda en PROPOSED: NO es reclamable hasta que un humano la apruebe en la consola del hub."
|
|
@@ -662,7 +731,7 @@ cmd_epic_status() {
|
|
|
662
731
|
if [ "$st" = "QUEUED" ] && [ -n "$cid" ]; then
|
|
663
732
|
ack="$(ops_epic_ack_read "$cid")"
|
|
664
733
|
if [ -n "$ack" ]; then
|
|
665
|
-
pidv="$(
|
|
734
|
+
pidv="$(_epic_proposal_id "$ack")"
|
|
666
735
|
st=PROPOSED
|
|
667
736
|
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$pidv")}"
|
|
668
737
|
else
|
|
@@ -686,13 +755,18 @@ cmd_epic_status() {
|
|
|
686
755
|
case "$st" in
|
|
687
756
|
APPROVED)
|
|
688
757
|
local code epic
|
|
689
|
-
|
|
690
|
-
|
|
758
|
+
# [#70] El código lo asigna el hub en `assigned_code` (EP-OR-15); `epic_code` era
|
|
759
|
+
# el nombre del borrador y se conserva como respaldo.
|
|
760
|
+
code="$(_claim_field "$resp" assigned_code)"
|
|
761
|
+
[ -n "$code" ] || code="$(_claim_field "$resp" epic_code)"
|
|
762
|
+
epic="$(_epic_from_response "$resp")"
|
|
691
763
|
[ -n "$epic" ] || epic="{}"
|
|
692
764
|
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"APPROVED\",\"epic_code\":$(ops_json_str "$code"),\"epic\":$epic}"
|
|
693
765
|
echo "$pidv APPROVED $code — escríbela con \`slice-ops.sh epic-writeback\`" ;;
|
|
694
766
|
REJECTED)
|
|
695
|
-
|
|
767
|
+
# [#70] El motivo del rechazo humano viaja en `reject_reason` (EP-OR-15).
|
|
768
|
+
local why; why="$(_claim_field "$resp" reject_reason)"
|
|
769
|
+
[ -n "$why" ] || why="$(_claim_field "$resp" reason)"
|
|
696
770
|
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"REJECTED\",\"reason\":$(ops_json_str "$why")}"
|
|
697
771
|
echo "$pidv REJECTED ${why:-sin motivo registrado}" ;;
|
|
698
772
|
*)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trycore/spec-build-harness",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,9 +31,11 @@ source "$ROOT/hooks/build/lib/runtime-ops.sh" 2>/dev/null || { echo "FAIL no exi
|
|
|
31
31
|
[ "$(ops_path_front_integration f-1 ak-1)" = "/fronts/f-1/members/ak-1/integration" ] && echo "OK ops_path_front_integration" || { echo "FAIL ops_path_front_integration"; fail=1; }
|
|
32
32
|
|
|
33
33
|
# [#62] Tabla de rutas de la propuesta de épica: un solo sitio donde se escriben rutas.
|
|
34
|
-
[
|
|
34
|
+
# [#70] El carril del AGENTE lleva `/agent/`: `/projects/{id}/epic-proposals` es la vista de
|
|
35
|
+
# consola (GET, JWT de usuario) y devuelve 405 a un POST — el drift que rompió el piloto.
|
|
36
|
+
[ "$(ops_path_epic_proposals p-1)" = "/projects/p-1/agent/epic-proposals" ] \
|
|
35
37
|
&& echo "OK ops_path_epic_proposals" || { echo "FAIL ops_path_epic_proposals"; fail=1; }
|
|
36
|
-
[ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/epic-proposals/prop-7" ] \
|
|
38
|
+
[ "$(ops_path_epic_proposal p-1 prop-7)" = "/projects/p-1/agent/epic-proposals/prop-7" ] \
|
|
37
39
|
&& echo "OK ops_path_epic_proposal" || { echo "FAIL ops_path_epic_proposal"; fail=1; }
|
|
38
40
|
|
|
39
41
|
# [#62] Ledger: upsert por client_event_id, sin duplicar, conservando lo no mencionado.
|
|
@@ -1618,9 +1620,13 @@ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_GA\"}"
|
|
|
1618
1620
|
# ══════════════ slice-ops.sh: propose-epic / epic-status / epic-writeback [#62] ══════════════
|
|
1619
1621
|
# Stub PROPIO (puerto, routes-ep.json y calls-ep.jsonl): routes-cl.json/STUB_CL ya no existen
|
|
1620
1622
|
# en este punto del fichero y mutarlos rompería aserciones aguas arriba.
|
|
1623
|
+
# [#70] Las rutas y los cuerpos de este bloque son los del CONTRATO DEL AGENTE del hub
|
|
1624
|
+
# (`contrato-agente-openapi.json`, EP-OR-14/EP-OR-15), no los del borrador de hub#113:
|
|
1625
|
+
# carril `/agent/…`, `layer` en MAYÚSCULAS (FOUNDATIONAL|BUSINESS), historias con
|
|
1626
|
+
# `description`+`acceptance[]`, y respuesta `{id, status, assigned_code, reject_reason}`.
|
|
1621
1627
|
PORT_EP="$(freeport)"
|
|
1622
1628
|
cat > "$TMP/routes-ep.json" <<JSON
|
|
1623
|
-
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"
|
|
1629
|
+
{"POST /projects/p-1/agent/epic-proposals": {"status": 201, "body": {"id": "prop-77", "project_id": "p-1", "title": "Notificaciones en tiempo real", "objective": "que el usuario vea los avisos sin recargar", "layer": "BUSINESS", "stories": [], "files_scope": [], "depends_on": [], "status": "PROPOSED", "assigned_code": null, "reject_reason": null}}}
|
|
1624
1630
|
JSON
|
|
1625
1631
|
: > "$TMP/calls-ep.jsonl"
|
|
1626
1632
|
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_EP" "$TMP/routes-ep.json" "$TMP/calls-ep.jsonl" &
|
|
@@ -1634,7 +1640,8 @@ cat > "$TMP/draft.json" <<'JSON'
|
|
|
1634
1640
|
{"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
|
|
1635
1641
|
"layer":"business","files_scope":["src/notifications/**"],"depends_on":["EP-012"],
|
|
1636
1642
|
"epic_code":"EP-999",
|
|
1637
|
-
"stories":[{"title":"ver el aviso","
|
|
1643
|
+
"stories":[{"title":"ver el aviso","description":"Como usuario quiero ver el aviso sin recargar",
|
|
1644
|
+
"acceptance_criteria":"Dado un aviso nuevo Cuando llega Entonces se ve. Dado un aviso leído Cuando entro Entonces no se repite"}]}
|
|
1638
1645
|
JSON
|
|
1639
1646
|
|
|
1640
1647
|
# legacy y dual: rc 3 sin efectos (ni cola, ni red).
|
|
@@ -1659,22 +1666,59 @@ out="$(bash "$SO" propose-epic --file "$TMP/draft.json" 2>&1)"; rc=$?
|
|
|
1659
1666
|
&& echo "OK propose-epic envia y devuelve el identificador" \
|
|
1660
1667
|
|| { echo "FAIL propose-epic online (rc $rc): $out"; fail=1; }
|
|
1661
1668
|
|
|
1662
|
-
# El
|
|
1669
|
+
# [#70] El cuerpo enviado valida contra `EpicProposalIn` del contrato del agente: `layer` en
|
|
1670
|
+
# MAYÚSCULAS, historias con `description`+`acceptance[]` y NUNCA `acceptance_criteria` (el
|
|
1671
|
+
# hub lo descarta en el borde con extra="ignore": la consola mostraba las HU sin criterios).
|
|
1663
1672
|
body="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
|
|
1664
1673
|
import json,sys
|
|
1665
1674
|
for l in open(sys.argv[1]):
|
|
1666
1675
|
d=json.loads(l)
|
|
1667
|
-
if d["path"]=="/projects/p-1/epic-proposals":
|
|
1668
|
-
b=json.loads(d["body"])
|
|
1669
|
-
|
|
1676
|
+
if d["path"]=="/projects/p-1/agent/epic-proposals":
|
|
1677
|
+
b=json.loads(d["body"]); errores=[]
|
|
1678
|
+
if b.get("layer")!="BUSINESS": errores.append("layer=%r" % b.get("layer"))
|
|
1679
|
+
if "epic_code" in b: errores.append("viajo el epic_code del borrador")
|
|
1680
|
+
if not b.get("title","").startswith("Notificaciones"): errores.append("title=%r" % b.get("title"))
|
|
1681
|
+
s=(b.get("stories") or [{}])[0]
|
|
1682
|
+
if "acceptance_criteria" in s: errores.append("la historia lleva acceptance_criteria")
|
|
1683
|
+
if set(s)-{"title","description","acceptance","code","acceptance_ref"}:
|
|
1684
|
+
errores.append("campos fuera del contrato: %s" % sorted(set(s)-{"title","description","acceptance","code","acceptance_ref"}))
|
|
1685
|
+
if s.get("description")!="Como usuario quiero ver el aviso sin recargar":
|
|
1686
|
+
errores.append("description=%r" % s.get("description"))
|
|
1687
|
+
acc=s.get("acceptance")
|
|
1688
|
+
if not isinstance(acc,list) or len(acc)!=2: errores.append("acceptance=%r" % acc)
|
|
1689
|
+
elif not acc[0].startswith("Dado un aviso nuevo") or not acc[1].startswith("Dado un aviso leído"):
|
|
1690
|
+
errores.append("escenarios mal partidos: %r" % acc)
|
|
1691
|
+
print("SI" if not errores else "NO: "+" | ".join(errores))
|
|
1670
1692
|
break
|
|
1671
1693
|
PY
|
|
1672
1694
|
)"
|
|
1673
|
-
[ "$body" = SI ] && echo "OK propose-epic
|
|
1674
|
-
|| { echo "FAIL
|
|
1695
|
+
[ "$body" = SI ] && echo "OK el cuerpo de propose-epic valida contra el contrato del agente" \
|
|
1696
|
+
|| { echo "FAIL cuerpo de propose-epic ($body)"; fail=1; }
|
|
1675
1697
|
echo "$out" | grep -qi 'ignorado' && echo "OK propose-epic avisa de que ignoro el codigo" \
|
|
1676
1698
|
|| { echo "FAIL sin aviso del codigo ignorado"; fail=1; }
|
|
1677
1699
|
|
|
1700
|
+
# [#70] `technical` no existe en el enum del hub (FOUNDATIONAL|BUSINESS): el 422 que devolvía
|
|
1701
|
+
# se evita mapeándolo a BUSINESS en el borde, no rechazando el borrador del agente.
|
|
1702
|
+
cat > "$TMP/draft-tech.json" <<'JSON'
|
|
1703
|
+
{"title":"Observabilidad","objective":"ver qué pasa en produccion","layer":"technical",
|
|
1704
|
+
"stories":[{"title":"ver las trazas"}]}
|
|
1705
|
+
JSON
|
|
1706
|
+
: > "$TMP/calls-ep.jsonl"
|
|
1707
|
+
bash "$SO" propose-epic --file "$TMP/draft-tech.json" >/dev/null 2>&1
|
|
1708
|
+
tech="$(python3 - "$TMP/calls-ep.jsonl" <<'PY'
|
|
1709
|
+
import json,sys
|
|
1710
|
+
for l in open(sys.argv[1]):
|
|
1711
|
+
d=json.loads(l)
|
|
1712
|
+
if d["path"]=="/projects/p-1/agent/epic-proposals":
|
|
1713
|
+
b=json.loads(d["body"])
|
|
1714
|
+
s=(b.get("stories") or [{}])[0]
|
|
1715
|
+
print("SI" if b.get("layer")=="BUSINESS" and "acceptance" not in s else "NO: layer=%r story=%r" % (b.get("layer"), s))
|
|
1716
|
+
break
|
|
1717
|
+
PY
|
|
1718
|
+
)"
|
|
1719
|
+
[ "$tech" = SI ] && echo "OK layer technical viaja como BUSINESS y una historia sin AC no inventa acceptance" \
|
|
1720
|
+
|| { echo "FAIL mapeo de layer technical ($tech)"; fail=1; }
|
|
1721
|
+
|
|
1678
1722
|
# El ledger recuerda la propuesta con su borrador.
|
|
1679
1723
|
ops_epic_ledger_read | python3 -c "
|
|
1680
1724
|
import json,sys
|
|
@@ -1710,14 +1754,18 @@ out="$(bash "$SO" propose-epic --file "$TMP/malo.json" 2>&1)"
|
|
|
1710
1754
|
|| { echo "FAIL validacion local del borrador: $out"; fail=1; }
|
|
1711
1755
|
|
|
1712
1756
|
# --- [#62] epic-status ---
|
|
1757
|
+
# [#70] La respuesta es `EpicProposalOut` del contrato: el código aprobado viaja en
|
|
1758
|
+
# `assigned_code` y la épica en la RAÍZ (no anidada en `epic`).
|
|
1713
1759
|
python3 - "$TMP/routes-ep.json" <<'PY'
|
|
1714
1760
|
import json,sys
|
|
1715
1761
|
p=sys.argv[1]; d=json.load(open(p))
|
|
1716
|
-
d["GET /projects/p-1/epic-proposals/prop-77"]={"status":200,"body":{
|
|
1717
|
-
"
|
|
1718
|
-
"
|
|
1719
|
-
|
|
1720
|
-
|
|
1762
|
+
d["GET /projects/p-1/agent/epic-proposals/prop-77"]={"status":200,"body":{
|
|
1763
|
+
"id":"prop-77","project_id":"p-1","status":"APPROVED","assigned_code":"EP-045",
|
|
1764
|
+
"title":"Notificaciones en tiempo real","objective":"que el usuario vea los avisos sin recargar",
|
|
1765
|
+
"layer":"BUSINESS","depends_on":["EP-012"],"files_scope":["src/notifications/**"],
|
|
1766
|
+
"stories":[{"code":"HU-310","title":"ver el aviso","description":"Como usuario…",
|
|
1767
|
+
"acceptance":["Dado un aviso nuevo Cuando llega Entonces se ve"],"acceptance_ref":None},],
|
|
1768
|
+
"reject_reason":None}}
|
|
1721
1769
|
json.dump(d,open(p,"w"))
|
|
1722
1770
|
PY
|
|
1723
1771
|
TRYCORE_RUNTIME_MODE=legacy bash "$SO" epic-status >/dev/null 2>&1
|
|
@@ -1733,20 +1781,38 @@ out="$(bash "$SO" epic-status 2>&1)"; rc=$?
|
|
|
1733
1781
|
|| { echo "FAIL epic-status (rc $rc): $out"; fail=1; }
|
|
1734
1782
|
ops_epic_ledger_read | python3 -c "
|
|
1735
1783
|
import json,sys
|
|
1736
|
-
|
|
1737
|
-
|
|
1784
|
+
ps=json.load(sys.stdin)['proposals']
|
|
1785
|
+
p=[q for q in ps if q.get('proposal_id')=='prop-77'][0]
|
|
1786
|
+
print('SI' if p['status']=='APPROVED' and p['epic_code']=='EP-045' and p['epic']['stories'][0]['code']=='HU-310' and p['epic']['title'].startswith('Notificaciones') else 'NO')" \
|
|
1738
1787
|
| grep -q SI && echo "OK epic-status guarda el codigo y el contenido aprobado" \
|
|
1739
1788
|
|| { echo "FAIL el ledger no recogio la aprobacion"; fail=1; }
|
|
1740
1789
|
|
|
1790
|
+
# [#70] El rechazo humano viaja en `reject_reason` (EP-OR-15): sin leerlo, la terminal decía
|
|
1791
|
+
# "sin motivo registrado" y el usuario no sabía qué corregir.
|
|
1792
|
+
python3 - "$TMP/routes-ep.json" <<'PY'
|
|
1793
|
+
import json,sys
|
|
1794
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
1795
|
+
d["GET /projects/p-1/agent/epic-proposals/prop-88"]={"status":200,"body":{
|
|
1796
|
+
"id":"prop-88","project_id":"p-1","status":"REJECTED","assigned_code":None,
|
|
1797
|
+
"title":"Otra","objective":"otra cosa","layer":"BUSINESS","depends_on":[],"files_scope":[],
|
|
1798
|
+
"stories":[],"reject_reason":"se solapa con EP-012"}}
|
|
1799
|
+
json.dump(d,open(p,"w"))
|
|
1800
|
+
PY
|
|
1801
|
+
ops_epic_ledger_upsert '{"client_event_id":"cid-r","proposal_id":"prop-88","status":"PROPOSED"}'
|
|
1802
|
+
out="$(bash "$SO" epic-status --id prop-88 2>&1)"; rc=$?
|
|
1803
|
+
[ $rc -eq 0 ] && echo "$out" | grep -q 'se solapa con EP-012' \
|
|
1804
|
+
&& echo "OK epic-status muestra el motivo del rechazo humano" \
|
|
1805
|
+
|| { echo "FAIL motivo del rechazo (rc $rc): $out"; fail=1; }
|
|
1806
|
+
|
|
1741
1807
|
# Un hub sin soporte (404) se dice con esas palabras y NO rompe nada.
|
|
1742
1808
|
python3 - "$TMP/routes-ep.json" <<'PY'
|
|
1743
1809
|
import json,sys
|
|
1744
1810
|
p=sys.argv[1]; d=json.load(open(p))
|
|
1745
|
-
d.pop("GET /projects/p-1/epic-proposals/prop-77",None)
|
|
1811
|
+
d.pop("GET /projects/p-1/agent/epic-proposals/prop-77",None)
|
|
1746
1812
|
json.dump(d,open(p,"w"))
|
|
1747
1813
|
PY
|
|
1748
1814
|
ops_epic_ledger_upsert '{"client_event_id":"cid-x","proposal_id":"prop-77","status":"PROPOSED"}'
|
|
1749
|
-
out="$(bash "$SO" epic-status 2>&1)"; rc=$?
|
|
1815
|
+
out="$(bash "$SO" epic-status --id prop-77 2>&1)"; rc=$?
|
|
1750
1816
|
[ $rc -eq 0 ] && echo "$out" | grep -qi 'sin soporte' \
|
|
1751
1817
|
&& echo "OK epic-status nombra la falta de soporte del hub" \
|
|
1752
1818
|
|| { echo "FAIL epic-status con 404 (rc $rc): $out"; fail=1; }
|