@trycore/spec-build-harness 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "trycore-spec-build-harness",
4
4
  "displayName": "Trycore — Spec & Build Harness",
5
- "version": "0.13.0",
5
+ "version": "0.14.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/METODOLOGIA.md CHANGED
@@ -542,7 +542,12 @@ al archivar (§6.4), el subárbol **`docs/adr/`** —propiedad de construcción
542
542
  arquitectura (§9.3), la **épica caparazón** en `docs/03-backlog/epicas.md` — únicamente esa épica,
543
543
  únicamente con **aprobación humana explícita** del borrador propuesto en `/build:onboard` Fase 2c
544
544
  (frontmatter `origin: harness-draft`); si el humano rechaza, la épica se crea en discovery como
545
- siempre —, y el subárbol **`docs/05-prototipo/`** —propiedad de construcción donde
545
+ siempre. En modo `runtime` ese carve-out cambia de forma: el arnés **no elige la identidad**
546
+ propone la épica al hub (`slice-ops.sh propose-epic`), un humano la aprueba y el fichero se
547
+ escribe después como **proyección** del grafo, con el `EP-XXX` que asignó el hub
548
+ (`origin: harness-writeback`, vía `/build:epic`). Es la misma frontera de escritura, con la
549
+ numeración fuera de las manos del agente: elegir el código mirando el fichero es lo que hace que
550
+ dos terminales colisionen —, y el subárbol **`docs/05-prototipo/`** —propiedad de construcción— donde
546
551
  `/build:prototype` (skill `prototyping-screens`) produce el prototipo HTML de referencia (la fuente
547
552
  de diseño): las pantallas nacen `borrador` y solo pasan a `aprobada` con aprobación humana, análogo
548
553
  a `docs/adr/`. Ni `docs/adr/` ni `docs/05-prototipo/` son artefactos de discovery: son salida de
package/README.md CHANGED
@@ -79,6 +79,7 @@ Gate de release que corre **una sola vez por versión** sobre el conjunto de sli
79
79
  | `/build:work` | Router *classify-and-act*: clasifica el trabajo entrante y enruta al carril correcto (`building-a-micro-change` · `building-a-slice` · `releasing-a-version`). Es ruteo, no política: no ejecuta el pipeline ni toca el estado. |
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
+ | `/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. |
82
83
  | `/opsx:*` (10) | Ciclo OpenSpec: `explore` · `new` · `continue` · `apply` · `verify` · `archive` · `bulk-archive` · `ff` · `onboard` · `sync`. Detalle → [`docs/commands.md`](docs/commands.md). |
83
84
 
84
85
  ## Gestión de contexto
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.13.0
1
+ 0.14.0
@@ -46,9 +46,16 @@ cumplen; lista cada una con ✓/✗:
46
46
  `slice-ops.sh status`. Si el proyecto es `greenfield` con caparazón **requerida** y la épica
47
47
  evaluada es `layer: business`: exige la caparazón **completada** (su épica archivada y con la
48
48
  checklist evidenciada). Si no lo está,
49
- **NO abras el slice**: instruye construir primero la épica caparazón (`foundation.epic`; si es
50
- `null`, correr `/build:onboard` Fase 2c o crearla en discovery). Las épicas `layer: foundational`
51
- no se bloquean por este criterio. Brownfield o `foundation.required !== true` → **N/A** (no
49
+ **NO abras el slice**: instruye construir primero la épica caparazón. Cuál es esa épica
50
+ depende del modo: en `legacy`/`dual` es `foundation.epic` de `build-state.json` y, si es
51
+ `null`, se define en `/build:onboard` Fase 2c o en discovery. En `runtime` **`foundation.epic`
52
+ no existe** —la proyección solo trae `foundation_done`, y la Fase 2c propone la épica al hub
53
+ sin código—, así que no lo pidas: comprueba con `slice-ops.sh epic-status` si hay una
54
+ propuesta ya `PROPOSED` y, si la hay, instruye consultar `/build:epic --check` y esperar la
55
+ aprobación, **no** repetir la Fase 2c; solo si no hay ninguna propuesta, Fase 2c o discovery.
56
+ Las épicas `layer: foundational`
57
+ no se bloquean por este criterio. Brownfield o caparazón no requerida (`foundation.required
58
+ !== true` en `legacy`/`dual`; `foundation_done: true` en `runtime`) → **N/A** (no
52
59
  bloquea, no preguntes). A diferencia del criterio 7, aquí NO evalúas arrastre: en greenfield
53
60
  el bloqueo es incondicional hasta que el cimiento exista archivado.
54
61
  8. **Tamaño acotado**: si la épica supera el umbral del gate de descomposición —heurística por defecto
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: "BUILD: Epic"
3
+ description: Propone una épica nueva al hub (modo runtime) y, tras la aprobación humana, la escribe en docs/03-backlog/epicas.md con el código que asignó el hub. Con --check no propone nada: solo consulta el estado de las propuestas y proyecta las ya aprobadas. El agente NO inventa el EP-XXX. Adaptador delgado sobre slice-ops.sh propose-epic/epic-status/epic-writeback.
4
+ category: Workflow
5
+ tags: [build-harness, runtime, backlog, epicas, trycore]
6
+ ---
7
+
8
+ # /build:epic — proponer una épica (la identidad la decide el hub)
9
+
10
+ Carril de las épicas **incrementales de construcción**: las que nacen en una terminal con el
11
+ proyecto ya registrado en el hub. La descomposición **inicial** del backlog sigue siendo de
12
+ discovery (`/trycore:epicas`, `@trycore/spec-product-flow`) y entra al hub por el import masivo
13
+ de admin — este comando no la sustituye.
14
+
15
+ **Regla dura**: el agente **no elige** el `EP-XXX`. Lo asigna el hub al aprobar. Elegirlo mirando
16
+ `epicas.md` es lo que hace que dos terminales creen la misma numeración y sus historias colisionen.
17
+
18
+ **Entrada (opcional):** `--check`. Sin argumento, el comando **propone** una épica nueva.
19
+
20
+ ## Despacho por argumento (léelo antes que nada)
21
+
22
+ El comando tiene **dos carriles** y no se recorren de arriba abajo:
23
+
24
+ - **Con `--check`** → ve directo al **§0** y luego **salta al §3**. `--check` es una consulta de
25
+ **solo lectura** sobre propuestas que ya existen: **no redactes ningún borrador y no propongas
26
+ nada** (§1 y §2 no se ejecutan, ni siquiera "por si acaso"). Es el carril que invocan el DoR
27
+ (`references/dor.md`) y el `dor-dod-gatekeeper` para averiguar si la épica caparazón ya está
28
+ propuesta: si ahí se propusiera una épica, la comprobación crearía **una segunda propuesta de
29
+ la misma épica** — exactamente la colisión que este comando existe para impedir.
30
+ - **Sin argumento** → §0 → §1 → §2. El §3 se recorre después, cuando el humano haya aprobado en
31
+ la consola del hub (normalmente en otra invocación, con `--check`).
32
+
33
+ ## 0. Precondición
34
+
35
+ ```bash
36
+ bash .claude/hooks/build/slice-ops.sh mode
37
+ ```
38
+
39
+ - `legacy` → **STOP**: no hay hub al que proponer. La épica se crea en discovery
40
+ (`/trycore:*`); si es la épica caparazón, por el carve-out de `/build:onboard` Fase 2c.
41
+ - `dual` → **STOP** igual, pero por otra razón: aquí **sí hay hub**, y el fichero local es el
42
+ primario. El carril de propuesta solo opera en `runtime`; la épica se crea como en `legacy` y
43
+ llega al hub por el import de admin. No lo diagnostiques como un problema de conexión.
44
+ - `runtime` → sigue.
45
+
46
+ ## 1. Redactar el borrador (con el usuario)
47
+
48
+ Reúne del repo y de `docs/` lo necesario y **propón** al usuario, vía **AskUserQuestion**:
49
+ título, objetivo, capa (`foundational` | `business` | `technical`), alcance de archivos y
50
+ dependencias con épicas existentes. Las historias van con AC en Given/When/Then.
51
+
52
+ No inventes la clasificación de capa ni las dependencias: derívalas del PRD / Story Map y
53
+ confírmalas. Sin aprobación explícita del usuario, **no propongas nada**.
54
+
55
+ Escribe el borrador en un fichero temporal (nunca por argv):
56
+
57
+ ```json
58
+ {
59
+ "title": "…",
60
+ "objective": "…",
61
+ "layer": "business",
62
+ "files_scope": ["src/…/**"],
63
+ "depends_on": ["EP-012"],
64
+ "stories": [{"title": "…", "acceptance_criteria": "Dado … Cuando … Entonces …"}]
65
+ }
66
+ ```
67
+
68
+ ## 2. Proponer
69
+
70
+ ```bash
71
+ bash .claude/hooks/build/slice-ops.sh propose-epic --file /tmp/epica-borrador.json
72
+ ```
73
+
74
+ | rc | Significado | Qué haces |
75
+ |---|---|---|
76
+ | `0` | Enviada; el hub devolvió el identificador de la propuesta | Dile al usuario que queda **pendiente de aprobación humana** en la consola del hub |
77
+ | `5` | Encolada, aún sin entregar (sin conexión, o el daemon estaba drenando la cola en ese instante) | Igual, avisando de que se despachará sola y de que `epic-status` la sigue |
78
+ | `6` | El hub la rechazó (o falta `project_id`) | Muestra la razón; **no** reintentes en bucle |
79
+ | `2` | Borrador inválido | Corrige el campo que indica el mensaje y repite |
80
+ | `3` | `legacy`/`dual` | Vuelve al paso 0 |
81
+
82
+ **No escribas `docs/03-backlog/epicas.md` aquí.** El fichero es una proyección del grafo: se
83
+ escribe cuando existe el código, y el código no existe hasta que un humano aprueba.
84
+
85
+ ## 3. Después de la aprobación — `/build:epic --check`
86
+
87
+ **Aquí aterriza `--check`** (viniendo del despacho de arriba). Los dos comandos son mecánicos:
88
+ uno consulta, el otro proyecta lo ya aprobado. Ninguno propone nada.
89
+
90
+ ```bash
91
+ bash .claude/hooks/build/slice-ops.sh epic-status
92
+ bash .claude/hooks/build/slice-ops.sh epic-writeback
93
+ ```
94
+
95
+ `epic-status` resuelve el estado de cada propuesta (`QUEUED` · `PROPOSED` · `APPROVED` ·
96
+ `REJECTED` · `FAILED`). `epic-writeback` escribe en `epicas.md` **solo** las `APPROVED` que
97
+ falten, con el código del hub, y es idempotente (`rc 7` = nada pendiente).
98
+
99
+ Tras escribir, revisa el bloque con el usuario y **no toques el código a mano**: si algo está
100
+ mal, se corrige en el hub y se vuelve a proyectar.
101
+
102
+ ## Guardrails
103
+
104
+ - El agente **propone**; la identidad la decide el hub y la aprobación es humana (gobierno,
105
+ METODOLOGIA §10 regla 8). Ni el modelo ni el arnés aprueban una épica.
106
+ - Los códigos de HU los pone el hub si los devuelve; el arnés **nunca** los inventa.
107
+ - El único fichero de `docs/` que este comando toca es `docs/03-backlog/epicas.md`, y solo con
108
+ un código ya asignado (carve-out de METODOLOGIA §9.2).
109
+ - En `legacy`/`dual` el comando no hace nada: el backlog vive en el fichero.
@@ -117,20 +117,45 @@ Si `project_kind !== "greenfield"`, salta esta fase (N/A total).
117
117
  - **Existe** → propónla al usuario y fija `foundation.epic`.
118
118
  - **No existe** → **borrador híbrido**: redacta la épica caparazón (título, objetivo, una HU
119
119
  por ítem `applies: true` con AC en Given/When/Then) y preséntala vía AskUserQuestion.
120
- - **Aprueba** escríbela en `docs/03-backlog/epicas.md` con frontmatter
121
- `layer: foundational` y `origin: harness-draft`, y fija `foundation.epic`.
122
- **Este es el ÚNICO caso en que el arnés escribe una épica** (carve-out de METODOLOGIA
123
- §9.2: solo la épica caparazón, solo con aprobación explícita).
120
+ - **Aprueba**, y `slice-ops.sh mode` dice `legacy`/`dual` → escríbela en
121
+ `docs/03-backlog/epicas.md` con frontmatter `layer: foundational` y
122
+ `origin: harness-draft`, y fija `foundation.epic`. **Este es el ÚNICO caso en que el
123
+ arnés escribe una épica por decisión propia** (carve-out de METODOLOGIA §9.2: solo la
124
+ épica caparazón, solo con aprobación explícita).
125
+ - **Aprueba**, y el modo es `runtime` → **no escribas el fichero**: propón la épica al hub
126
+ (la identidad la asigna él, issue #62) y deja `foundation.epic: null` hasta que vuelva
127
+ con código:
128
+
129
+ ```bash
130
+ bash .claude/hooks/build/slice-ops.sh propose-epic --file /tmp/epica-caparazon.json
131
+ ```
132
+
133
+ Explica al usuario que la épica queda **pendiente de aprobación humana** en la consola
134
+ del hub y que, una vez aprobada, `/build:epic --check` la escribe en `epicas.md` con su
135
+ `EP-XXX`. El hecho de fundación se reporta igual en el paso 3 (`--epic` no viaja al hub).
124
136
  - **Rechaza** → **STOP** de la fase: deja `foundation.epic: null`, instruye crearla en
125
137
  discovery (`/trycore:*`) con la checklist como alcance. El gate del DoR bloqueará las
126
138
  épicas de negocio igual hasta que exista y se archive.
127
139
  3. **Reportar el hecho**: escribe la checklist podada en un fichero temporal
128
- (`[{"id","applies","evidence":""}, …]`) y repórtala en una sola transición:
140
+ (`[{"id","applies","evidence":""}, …]`) y repórtala en una sola transición. El valor de
141
+ `--epic` depende de si ya tienes un `EP-XXX` real:
129
142
 
130
- ```bash
131
- bash .claude/hooks/build/slice-ops.sh fact foundation \
132
- --required true --epic EP-XXX --checklist-file /tmp/foundation-checklist.json
133
- ```
143
+ - **Existe / `legacy`/`dual` con épica ya escrita** → pasa el código real:
144
+
145
+ ```bash
146
+ bash .claude/hooks/build/slice-ops.sh fact foundation \
147
+ --required true --epic EP-XXX --checklist-file /tmp/foundation-checklist.json
148
+ ```
149
+
150
+ - **`runtime` con la propuesta aún sin aprobar** (`foundation.epic: null`) → **omite `--epic`
151
+ por completo**. No inventes un `EP-XXX` de relleno: el flag ni se valida ni viaja al hub
152
+ (`_fact_foundation` en `slice-ops.sh` lo acepta solo por compat local; el hub solo recibe
153
+ `--required`), así que no hay coste mecánico en omitirlo — y sí lo hay en escribir uno falso.
154
+
155
+ ```bash
156
+ bash .claude/hooks/build/slice-ops.sh fact foundation \
157
+ --required true --checklist-file /tmp/foundation-checklist.json
158
+ ```
134
159
 
135
160
  `--required false` cuando el humano podó todos los ítems (no hay caparazón exigible).
136
161
  En modo legacy (rc 3) se escribe en el fichero con su protocolo.
@@ -62,8 +62,10 @@ fichero en `legacy`):
62
62
  - **Caparazón (solo greenfield)**: si `project_kind` es `greenfield` y `foundation_done` es `false`, y
63
63
  la épica objetivo es `layer: business` → **STOP**: solo la épica caparazón u otra fundacional puede
64
64
  abrir. `status` no identifica **cuál** es la épica caparazón (eso vive hoy en el fichero legacy,
65
- leído directo por `dor-dod-gatekeeper`); pregunta o consúltalo ahí. Lo valida en detalle el
66
- `dor-dod-gatekeeper` (criterio 7-bis). Brownfield N/A, no preguntes.
65
+ leído directo por `dor-dod-gatekeeper`); pregunta o consúltalo ahí. En modo `runtime` puede no
66
+ existir todavía porque la propuesta está **pendiente de aprobación del hub**
67
+ (`/build:epic --check` lo dice): entonces el STOP es **esperar**, y no volver a proponerla.
68
+ Lo valida en detalle el `dor-dod-gatekeeper` (criterio 7-bis). Brownfield → N/A, no preguntes.
67
69
 
68
70
  El hook `scaffold-guard.sh` respalda esto en tiempo real.
69
71
 
@@ -33,8 +33,12 @@ Aplica las reglas en orden:
33
33
  `greenfield` y `foundation_done` es `false` → **enruta directo a construir la épica caparazón**
34
34
  por el carril **`building-a-slice`**. `status` no trae **cuál** es esa épica (`foundation.epic`
35
35
  vive hoy en el fichero legacy, leído directo por `dor-dod-gatekeeper`); si no la encuentras o
36
- está sin definir, enruta a `/build:onboard` (Fase 2c) o a discovery para definirla. Ninguna épica de
37
- negocio pasa por delante. Brownfield, o caparazón no requerida (`foundation_done` ya `true`) →
36
+ está sin definir, enruta a `/build:onboard` (Fase 2c) o a discovery para definirla **salvo en
37
+ modo `runtime`**, donde `foundation.epic` sin definir puede ser una propuesta **pendiente de
38
+ aprobación del hub**, no un olvido: consulta antes `slice-ops.sh epic-status` (o
39
+ `/build:epic --check`) y, si ya hay una propuesta `PROPOSED`, el destino es **esperar la
40
+ aprobación**, no re-onboardear (re-correr la Fase 2c propondría una segunda vez la misma épica
41
+ caparazón, issue #62). Ninguna épica de negocio pasa por delante. Brownfield, o caparazón no requerida (`foundation_done` ya `true`) →
38
42
  esta regla es N/A.
39
43
  Esta regla gatea **aperturas de slice** (épicas): el carril de **mantenimiento**
40
44
  (`building-a-micro-change`, regla 1) no se bloquea — un typo/copy/config fix sigue su carril
@@ -7,6 +7,7 @@
7
7
  },
8
8
  "runtime": {
9
9
  "mode": "legacy",
10
- "stale_seconds": 900
10
+ "stale_seconds": 900,
11
+ "context_refresh_s": 45
11
12
  }
12
13
  }
@@ -225,6 +225,19 @@ function syncGitignoreBlock(targetDir, mode) {
225
225
  '.claude/state/runtime-projection.json',
226
226
  '.claude/state/.runtime-http-status',
227
227
  '.claude/state/outbox/',
228
+ // [Ronda de arreglo 1, Minor 7 / #63] El ledger local de propuestas de épica y el
229
+ // marcador de desfase de grafo son estado derivado del hub, igual que los de arriba —
230
+ // sin esto, un consumidor los commitea por descuido.
231
+ '.claude/state/epic-proposals.json',
232
+ '.claude/state/graph-status.json',
233
+ // [Ronda final · C5] Estado del daemon de heartbeat (EP-OR-08-B): un pid y unos ppids
234
+ // solo significan algo en LA máquina que los escribió. Commiteados, otro clon recibe un
235
+ // pidfile ajeno que el relanzamiento da por bueno hasta el `kill -0`, y un
236
+ // `heartbeat-status.json` con el lease de otro. Hueco preexistente, cerrado aquí porque
237
+ // esta rama ya edita este bloque.
238
+ '.claude/state/heartbeat.pid',
239
+ '.claude/state/heartbeat-sessions.json',
240
+ '.claude/state/heartbeat-status.json',
228
241
  ];
229
242
  if (mode === 'symlink') {
230
243
  // Los symlinks absolutos a node_modules global se rompen en otra máquina si se commitean.
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { readPackageVersion, targetPaths, PACKAGE_ROOT } from '../lib/paths.js';
5
5
  import { countChildren } from '../lib/install-engine.js';
6
6
  import { hasBinary } from './doctor.js';
7
- import { runtimeMode, getAgentContext, readProjection, readLock, rejectedStats } from '../lib/runtime-client.js';
7
+ import { runtimeMode, getAgentContext, readProjection, readLock, rejectedStats, readGraphStatus } from '../lib/runtime-client.js';
8
8
  export async function status(opts) {
9
9
  const targetDir = path.resolve(opts.targetDir);
10
10
  const t = targetPaths(targetDir);
@@ -45,6 +45,18 @@ export async function status(opts) {
45
45
  if (proj.fetched_at) {
46
46
  const ageS = Math.max(0, Math.floor((Date.now() - Date.parse(String(proj.fetched_at))) / 1000));
47
47
  console.log(` Proyección: ✓ v${proj.context?.version ?? '?'} (hace ${ageS}s${ageS > 900 ? ' — ⚠ stale' : ''})`);
48
+ // [#63] La versión de grafo solo se muestra si el hub la expone: una instancia que no
49
+ // versiona no debe aparentar que sí.
50
+ const graphVersion = proj.context?.graph_version;
51
+ if (typeof graphVersion === 'number') {
52
+ // [Ronda de arreglo 1, Minor 5] El CHANGELOG dice que este comando avisa del desfase,
53
+ // no solo `slice-ops.sh status` — mismo criterio que el bash (Important 3): «servidor
54
+ // MAYOR que local», nunca `!==`, para no acusar desfase con un marcador de grafo viejo
55
+ // que apunta a una versión que la proyección ya superó.
56
+ const gs = readGraphStatus(targetDir);
57
+ const behind = typeof gs.server === 'number' && gs.server > graphVersion;
58
+ console.log(` Grafo: v${String(graphVersion)}${behind ? ` ⚠ por detrás del hub (v${gs.server}): refresca antes de reclamar o proponer` : ''}`);
59
+ }
48
60
  const slice = proj.active_slice;
49
61
  if (slice) {
50
62
  console.log(` Slice activo: ${slice.epic_code ?? '?'} (fase ${slice.phase ?? '?'})`);
package/dist/lib/paths.js CHANGED
@@ -69,6 +69,7 @@ export function targetPaths(targetDir) {
69
69
  runtimeCredentialsFile: path.join(claudeDir, 'state', 'runtime.credentials'),
70
70
  runtimeLockFile: path.join(claudeDir, 'state', 'context.lock'),
71
71
  runtimeProjectionFile: path.join(claudeDir, 'state', 'runtime-projection.json'),
72
+ graphStatusFile: path.join(claudeDir, 'state', 'graph-status.json'),
72
73
  outboxDir: path.join(claudeDir, 'state', 'outbox'),
73
74
  settingsFile: path.join(claudeDir, 'settings.json'),
74
75
  versionFile: path.join(claudeDir, '.build-harness-version'),
@@ -11,6 +11,7 @@ function runtimePaths(targetDir) {
11
11
  credentials: t.runtimeCredentialsFile,
12
12
  lock: t.runtimeLockFile,
13
13
  projection: t.runtimeProjectionFile,
14
+ graphStatus: t.graphStatusFile,
14
15
  outboxDir: t.outboxDir,
15
16
  buildConfigFile: t.buildConfigFile,
16
17
  };
@@ -161,6 +162,25 @@ export function readLock(targetDir) {
161
162
  return { manifestHash: null, syncedAt: null, fileCount: 0 };
162
163
  }
163
164
  }
165
+ /**
166
+ * Lectura fail-open del marcador de desfase de grafo (.claude/state/graph-status.json),
167
+ * escrito por `runtime_graph_note_stale` (hooks/build/lib/runtime-client.sh) cuando el hub
168
+ * rechaza `claim` o una propuesta con `409 stale_graph` [#63]. Sin fichero (nunca hubo
169
+ * desfase) o con JSON ilegible, ambos campos quedan `null` — nunca lanza.
170
+ */
171
+ export function readGraphStatus(targetDir) {
172
+ const p = runtimePaths(targetDir).graphStatus;
173
+ try {
174
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
175
+ return {
176
+ local: typeof parsed?.local === 'number' ? parsed.local : null,
177
+ server: typeof parsed?.server === 'number' ? parsed.server : null,
178
+ };
179
+ }
180
+ catch {
181
+ return { local: null, server: null };
182
+ }
183
+ }
164
184
  /** Estadísticas fail-open de la cola offline (.claude/state/outbox/*.json, sin dotfiles). */
165
185
  export function outboxStats(targetDir) {
166
186
  const dir = runtimePaths(targetDir).outboxDir;
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` — superficie de agente del runtime (beta, opt-in)
132
+ ### `/build:claim`, `/build:status`, `/build:escalate`, `/build:epic` — 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.
@@ -139,6 +139,7 @@ protocolo del fichero (`/build:slice`) — no rompen el flujo normal si el proye
139
139
  | `/build:claim [EP-XXX]` | 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. |
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
+ | `/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. |
142
143
 
143
144
  ---
144
145
 
package/docs/hooks.md CHANGED
@@ -35,7 +35,7 @@ Los hooks son el sistema nervioso del arnés: vigilan GitFlow, el stack declarad
35
35
  | `dual-compare.sh` | `Stop` | `.*` | (Beta, solo modo `dual`.) Detecta cuando la proyección del runtime diverge del fichero local (épica/fase/gates ya resueltos) y escala vía el mismo canal que `/build:escalate`. Es el instrumento de medición del piloto que condiciona el corte a `runtime` — nunca bloquea el cierre de sesión. | No |
36
36
  | `session-stop.sh` | `Stop` | `.*` | (Beta.) Deja `outbox/.flush-request` y asegura el daemon `heartbeat.sh` — el flush de la cola offline lo ejecuta el daemon, no el hook. No-op en `legacy`. | No |
37
37
  | `context-sync.sh` | invocado por `session-start.sh` y, pre-claim, por las skills (no registrado como hook) | — | (Beta.) Sincronización de contexto *content-addressed*: compara el `manifest_hash` esperado contra el lock local; si difiere, descarga solo los archivos gobernados (`config/`, `rules/`, `docs-cache/`) que cambiaron, por hash. Nunca escribe fuera de esos prefijos. Fail-open: sin runtime, sin `python3` o con manifiesto ilegible, deja el lock anterior intacto. | No |
38
- | `heartbeat.sh` | daemon singleton por repo, lanzado por `session-start.sh` (no registrado como hook) | — | (Beta.) Renueva el lease (`PUT /leases/renew`) y despacha la cola offline mientras viva al menos una sesión interesada (`.claude/state/heartbeat-sessions.json`). No existe evento Timer en Claude Code; por eso el latido es un daemon, no un hook. |
38
+ | `heartbeat.sh` | daemon singleton por repo, lanzado por `session-start.sh` (no registrado como hook) | — | (Beta.) Renueva el lease (`PUT /leases/renew`), **refresca la caché de proyección desde `GET /agent/context` con su propia cadencia** (`runtime.context_refresh_s`, default 45 s; solo en modo `runtime`) y despacha la cola offline mientras viva al menos una sesión interesada (`.claude/state/heartbeat-sessions.json`). No existe evento Timer en Claude Code; por eso el latido es un daemon, no un hook. |
39
39
 
40
40
  > Cuatro bloqueantes (`gitflow-guard`, `stack-guard`, `scaffold-guard`, `design-source-guard`) y quince informativos. Los bloqueantes usan `exit 2`: Claude Code devuelve el `stderr` al modelo y aborta la herramienta; el resto siempre sale `0`. Los 4 bloqueantes **nunca** tocan la red (ni en `legacy` ni en `dual`/`runtime`) — ratchet estático en `scripts/tests/test-hooks-runtime.sh`.
41
41
  >
@@ -193,6 +193,16 @@ tick (`TRYCORE_HEARTBEAT_TICK_S`, default 5s): consume `outbox/.flush-request` y
193
193
  cada `lease_ttl_s/3` (mínimo 10s), `PUT /leases/renew`. **Sin lease no se apaga**: los eventos de
194
194
  ámbito proyecto (pre-claim) necesitan despachador igual.
195
195
 
196
+ **Refresco de contexto (#61)**. Cada `runtime.context_refresh_s` segundos (default 45; `0` lo
197
+ desactiva; override por `TRYCORE_CONTEXT_REFRESH_S`) el daemon hace `GET /agent/context` y
198
+ reescribe la caché de proyección. Es la única vía por la que una terminal **abierta** se entera
199
+ de algo nuevo: `session-start.sh` hidrata una vez y `slice-ops.sh` solo en `claim`/`status`, y el
200
+ `claim` ocurre una vez por slice. La cadencia es propia y **no** el tick de 5 s: renovar un lease
201
+ es barato, pedir la proyección entera del proyecto no. Si el refresco falla, la caché anterior
202
+ queda **intacta** y se marca `context_stale: true` en `heartbeat-status.json` (lo muestra
203
+ `slice-ops.sh status`): quedarse sin proyección convertiría al agente en huérfano, que es
204
+ justo lo que arregla el PR #60. Solo corre en modo `runtime`.
205
+
196
206
  ---
197
207
 
198
208
  ## La cadena de comando única (sin doble disparo entre canales)
@@ -59,6 +59,15 @@ Reglas del cliente (las implementa `slice-ops.sh claim`, no la prosa de la skill
59
59
  (rc 2, sin abrir socket) explicando el protocolo real: terminar el slice en dual → cutover
60
60
  admin → reclamar del hub lo que la cola reparta.
61
61
 
62
+ **Versión de grafo (#63 / hub#115).** `GET /agent/context` expone `context.graph_version`; el
63
+ cliente la manda en `POST /tasks/next` y en la propuesta de épica **solo si la conoce** (un hub
64
+ que no versiona ve el cuerpo de siempre). Un `409` con `{"reason":"stale_graph","graph_version":
65
+ M}` no es un error: se anota el desfase en `.claude/state/graph-status.json`, se refresca la
66
+ proyección y se reintenta **una vez**; un segundo rechazo sale con un mensaje que nombra el
67
+ desfase y la acción. En el carril directo la versión se estampa **al despachar**, nunca al
68
+ encolar — sellarla al encolar condenaría al 409 a todo despacho diferido —, y tras tres
69
+ rechazos consecutivos la petición se aparta en vez de reintentarse en bucle.
70
+
62
71
  Actos de dominio posteriores (todos por `slice-ops.sh`, ninguno a mano):
63
72
  `POST /slices/{id}/verdicts` · `POST /checkpoints` · `POST /slices/{id}/submit` · eventos
64
73
  `wiring_*`, `progress_noted`, `slice_archived`, `slice_escalated` y los hechos de
@@ -102,6 +111,32 @@ Offline: sin runtime se trabaja con el último lock; la statusline marca `⚠ st
102
111
  - Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `checkpoint_recorded`, `gate_verdict`, `slice_escalated`, `slice_submitted`, `slice_archived`, `wiring_*`, `project_fact_updated`, `handoff_recorded` ni el propio `telemetry_gap` (nombres del catálogo v2; los alias 0.10.x de los cuatro renombrados siguen protegidos porque la capa de compat los entrega). El descarte se reporta como evento `telemetry_gap` con el payload del catálogo `{dropped, window_h, reason}` (issue #44), coalescido en un único gap mientras la cola siga sobre la cota.
103
112
  - Flush forzado en `Stop`: `session-stop.sh` deja el sentinela `outbox/.flush-request`; el daemon `heartbeat.sh` lo consume de forma asíncrona (nunca en el hilo del hook). `trycore-build doctor` **reporta** el tamaño/edad de la cola pero **no** dispara un flush síncrono — sigue sin implementar.
104
113
 
114
+ **Carril directo** (`channel: "direct"`). Un fichero de la outbox puede llevar `channel:
115
+ "direct"`, `method` y `path`: entonces no entra en el lote de `POST /events`, se despacha solo a
116
+ su endpoint. Comparte directorio, formato, cota (`RUNTIME_OUTBOX_PROTECTED`) y sentinela de
117
+ flush con la cola de eventos.
118
+
119
+ **Idempotencia del carril directo — contrato asumido de hub#113.** El cuerpo de cada petición
120
+ del carril directo lleva `client_event_id` (el mismo UUID que nombra el fichero de la outbox) como
121
+ **clave de idempotencia**: el servidor debe tratar dos peticiones con el mismo `client_event_id`
122
+ como **una sola** y devolver el mismo recurso en la segunda, igual que hace `POST /events` con los
123
+ `duplicates[]`. No es opcional. Hay **dos despachadores** posibles sobre la misma cola —el daemon
124
+ de heartbeat y el proceso en primer plano de la skill (`slice-ops.sh propose-epic`)—; el cliente
125
+ los serializa con un mutex de directorio (`.claude/state/.outbox-dispatch.lock`, `mkdir` atómico,
126
+ liberación por edad a los 2 min), pero el mutex es **por repo**: dos worktrees del mismo proyecto,
127
+ o un reintento tras un corte a mitad de respuesta, siguen pudiendo entregar la misma propuesta dos
128
+ veces. Sin deduplicación server-side eso son **dos propuestas** y, al aprobarlas, **dos `EP-XXX`**
129
+ para la misma épica — justo la colisión de numeración que el carril existe para evitar. Un
130
+ `client_event_id` ya presente en el payload del productor **manda** (no se sobrescribe); el resto
131
+ del cuerpo no se toca.
132
+
133
+ Resultado por petición: `2xx` → acuse en `outbox/acks/<cid>.json`
134
+ y borrado; `404` → **instancia del hub sin soporte**, se aparta a `rejected/` diciéndolo;
135
+ `4xx` (salvo `408`/`429`) → rechazo de contrato, se aparta; `000`/`5xx`/`408`/`429` → se
136
+ conserva y se reintenta en el siguiente ciclo. No agenda backoff propio: es de volumen
137
+ bajísimo y compartir el del lote dejaría los hechos de dominio esperando detrás. La ruta la
138
+ estampa `lib/runtime-ops.sh` al encolar — `runtime-client.sh` no conoce la tabla de endpoints.
139
+
105
140
  ## 6. Declaración de tipos de asset (`asset-types.json` del paquete)
106
141
 
107
142
  El paquete del plugin declara los tipos de documento que sabe generar. En el registro, el runtime hace upsert idempotente (aditivos auto-publicados; breaking quedan propuestos para un ADMIN):
@@ -146,6 +181,7 @@ agente jamás publica contexto.**
146
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. |
147
182
  | Timeout/red caída | Modo offline (§4/§5); jamás bloquear PreToolUse ni el trabajo local. |
148
183
  | `409` en el renew de lease (`PUT /leases/renew`) | **No existe 410**: el servidor responde siempre `409` de cuerpo único (anti-oráculo). El daemon marca `lease_lost` en `.claude/state/heartbeat-status.json`, **no se apaga** (sigue despachando la cola) y la statusline muestra `⚠ lease`. La skill detiene el trabajo, hace checkpoint local y vuelve a reclamar. |
184
+ | `409` `stale_graph` en `tasks/next` o en la propuesta | Grafo local rancio: anotar desfase, refrescar y reintentar **una vez**; segundo rechazo → `rc 6` con el desfase nombrado. En la cola: conservar y re-estampar con la versión del momento; se aparta al **tercer rechazo contra la MISMA versión local estampada** (si la versión cambió entre medias, hubo refresco de verdad y la racha vuelve a cero). El contador va con su versión (`graph_409`, `graph_409_at_version`) dentro del fichero de la outbox: contar rechazos a secas agotaría las tres vidas de la propuesta sin que hubiera ocurrido ni un refresco, porque el despacho corre en cada `Stop` mientras el refresco de contexto va a `runtime.context_refresh_s` (45 s, y `0` lo **desactiva**). |
149
185
  | Reloj local desviado | El cliente usa siempre `expires_at` del servidor, nunca calcula TTL con reloj propio. |
150
186
 
151
187
  ## 8. Seguridad del cliente
@@ -163,6 +199,13 @@ No existe evento Timer, los hooks son efímeros y un `PostToolUse` throttled no
163
199
  - **Muerte**: cuando no queda **ningún** ppid registrado vivo (dos sesiones sobre el mismo repo comparten daemon; cerrar la primera no lo mata). Nunca se cuelga de `Stop` ni de `SessionEnd`.
164
200
  - **Deberes por tick** (`TRYCORE_HEARTBEAT_TICK_S`, default 5 s): consumir `outbox/.flush-request` y despachar; cada `lease_ttl_s/3` (valor del servidor, mínimo 10 s), `PUT /leases/renew`. **Sin lease no se apaga**: los eventos de ámbito proyecto (pre-claim) necesitan despachador.
165
201
 
202
+ **Deberes por tick** (cadencias independientes): consumir el sentinela de flush y despachar la
203
+ cola; cada `lease_ttl_s/3`, renovar el lease; cada `runtime.context_refresh_s` (default 45 s),
204
+ `GET /agent/context` para refrescar la proyección local. El refresco de contexto es la
205
+ contrapartida cliente de los `nudges` del servidor: el hub ya los emite y el cliente ya los
206
+ normaliza y los pinta — lo que faltaba era que alguien leyera con regularidad. Un refresco
207
+ fallido nunca degrada la caché: se conserva la anterior y se marca `context_stale`.
208
+
166
209
  ## 10. Operaciones de skill (`slice-ops.sh` / `release-ops.sh`)
167
210
 
168
211
  Las skills son prosa: **no** arman peticiones. Cada acto de dominio pasa por un ejecutable
@@ -185,10 +228,31 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
185
228
  | `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
186
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) |
187
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 (hub#113). `0` enviada · `5` encolada · `6` rechazada · `3` legacy/dual. Un `epic_code` en el borrador se **ignora**, no se rechaza |
232
+ | `slice-ops.sh epic-status [--id P]` | `GET /projects/{id}/epic-proposals/{P}`. Resuelve el ciclo: `QUEUED` → `PROPOSED` → `APPROVED` (con `epic_code`) / `REJECTED`. Informativo: `rc 0` siempre en runtime |
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 |
188
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) |
189
235
  | `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
190
236
  | `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration`; **sin fallback offline** (rc 5: `front_integration_reported` no existe en el catálogo v2, issue #44) |
191
237
 
238
+ **Forma del borrador de `propose-epic --file`:**
239
+
240
+ ```json
241
+ {
242
+ "title": "…",
243
+ "objective": "…",
244
+ "layer": "foundational|business|technical",
245
+ "files_scope": ["…"],
246
+ "depends_on": ["EP-012"],
247
+ "stories": [{"title": "…", "acceptance_criteria": "…"}]
248
+ }
249
+ ```
250
+
251
+ `title` y `objective` son obligatorios; `layer` por defecto `business` (rc 2 si no es una de las
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 (hub#113). El normalizador estampa
254
+ `origin: "harness-draft"` antes de encolar.
255
+
192
256
  **Códigos de salida** (contrato con la prosa): `0` ok · `2` uso · `3` modo legacy (o claim en dual)
193
257
  · `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) ·
194
258
  `7` sin trabajo.
@@ -18,7 +18,18 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
18
  source "$HERE/lib/projection.sh"
19
19
  if [ "$(runtime_mode)" = "runtime" ]; then
20
20
  CACHE="$(runtime_projection_path)"
21
- [ -f "$CACHE" ] || exit 0
21
+ if [ ! -f "$CACHE" ]; then
22
+ # auto-arme (sin credenciales) vs desconectado (con credenciales): ver
23
+ # runtime_disconnected en lib/runtime-client.sh.
24
+ if runtime_disconnected; then
25
+ echo "⛔ sin conexión con el hub: hay credenciales del proyecto pero no hay proyección local." >&2
26
+ echo " Este agente no sabe qué trabajo tiene asignado, así que no puede escribir código." >&2
27
+ echo " Recupérala con \`slice-ops.sh status\` (refresca /agent/context) y reclama con \`/build:claim\`." >&2
28
+ echo " Si esto es un worktree: el estado del arnés vive en el clon principal y no viaja al worktree." >&2
29
+ exit 2
30
+ fi
31
+ exit 0
32
+ fi
22
33
  if ! command -v python3 >/dev/null 2>&1; then
23
34
  echo "⛔ design-source-guard: python3 no disponible; no puedo verificar el gate de fuente de diseño. Instala python3 (trycore-build doctor)." >&2
24
35
  exit 2