@trycore/spec-build-harness 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.15.0",
5
+ "version": "0.16.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/GOVERNANCE.md CHANGED
@@ -8,7 +8,7 @@ evoluciona con el modelo y el proyecto. No es "instalar y olvidar".
8
8
  |---|---|---|
9
9
  | Contexto | sección Construcción de CLAUDE.md, `openspec/project.md` | raíz / `openspec/` |
10
10
  | Estado (modo `legacy`, default) | `build-state.json` (+schema, README) | `.claude/state/` |
11
- | Cliente runtime (modo `dual`/`runtime`, opt-in — beta) | `runtime.credentials` (0600), `context.lock`, `runtime-projection.json`, `outbox/`; `slice-ops.sh`/`release-ops.sh` (13 subcomandos) | `.claude/state/`, `.claude/hooks/build/` |
11
+ | Cliente runtime (modo `dual`/`runtime`, opt-in — beta) | `runtime.credentials` (0600), `context.lock`, `runtime-projection.json`, `outbox/`; `slice-ops.sh`/`release-ops.sh` (20 + 3 subcomandos) | `.claude/state/`, `.claude/hooks/build/` |
12
12
  | Agentes | 14 agentes de build | `.claude/agents/build/` |
13
13
  | Hooks | settings.json + 19 scripts (15 registrados + 4 invocados: `reconcile-build-state.py`, `context-sync.sh`, `heartbeat.sh` como daemon, `statusline-bridge.sh` como comando `statusLine`) | `.claude/settings.json`, `.claude/hooks/build/` |
14
14
  | Skill | `building-a-slice` (+11 refs · `workflows/`) · `releasing-a-version` (`workflows/`) · `building-a-micro-change` (carril ligero de mantenimiento) · `managing-parallel-front` (front paralelo inter-épica) · `prototyping-screens` (prototipo HTML de referencia) | `.claude/skills/` |
package/INSTALL.md CHANGED
@@ -107,7 +107,7 @@ Qué hace `init`:
107
107
  1. **Verifica requisitos duros** (a menos que uses `--skip-doctor`).
108
108
  2. **Siembra los assets** en rutas nativas de Claude Code:
109
109
  - `.claude/agents/build/` — 14 agentes.
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`.
110
+ - `.claude/commands/opsx/` (10 comandos `/opsx:*`) y `.claude/commands/build/` (14 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`, `/build:graph-sync`). Los cinco últimos son la **superficie de agente del modo runtime** (§9): en `legacy`, `claim`, `escalate`, `epic` y `graph-sync` devuelven `rc 3` remitiendo al flujo del fichero, y `status` informa de que la fuente de verdad es `build-state.json`.
111
111
  - `.claude/skills/` — 16 skills (`building-a-slice`, `building-a-micro-change`, `releasing-a-version`, `managing-parallel-front`, `setup-architecture`, `prototyping-screens`, `openspec-*`).
112
112
  - `.claude/hooks/build/` — 19 hooks (bash + python; 6 son del cliente runtime opt-in — §9).
113
113
  3. **Siembra el estado**: `state/build-state.schema.json` y `state/README.md` se versionan;
package/METODOLOGIA.md CHANGED
@@ -332,15 +332,26 @@ worktree— y coordina la selección, la construcción y el merge desde el estad
332
332
  (`parallel_front`). Es un concepto de **outer-loop**: no es una fase nueva del pipeline por-épica, es
333
333
  orquestación **entre** épicas.
334
334
 
335
+ **Compuerta cero: un agente por worktree, un token por agente.** En modo `runtime`, cada worktree
336
+ se da de alta como **agente propio** con un token que el ADMIN emitió **para él**
337
+ (`slice-ops.sh register`, que lo pide por stdin). No es un trámite: el hub deriva el `agent_key`
338
+ del `agent_id` grabado en el token, así que dos worktrees con el mismo token son **un solo agente**
339
+ y el segundo `claim` devuelve el slice del primero. Y un worktree **sin** credenciales propias no
340
+ queda huérfano — hereda las del clon principal y **suplanta** a ese agente. Por eso toda escritura
341
+ desde un árbol sin identidad propia aborta con `rc 9` (issue #74).
342
+
335
343
  **Compuertas del front:**
336
344
 
337
345
  1. **Foundational-first (G1).** Ninguna épica `layer: foundational` entra jamás al front: el
338
346
  cimiento se construye secuencial, antes que el negocio (§1-bis.2, §3.1). Solo épicas
339
347
  `layer: business` son candidatas a paralelizarse.
340
- 2. **Disjunción por `files_scope` (G2).** `scripts/lib/front-plan.py` calcula, a partir de los globs
341
- declarados en `files_scope` de cada épica candidata, qué subconjunto es mutuamente disjunto
342
- (`selected`, va al front) y cuál se solapa (`serialized`, espera y se construye después,
343
- secuencial). Sin `files_scope` declarado, una épica no es candidata al front.
348
+ 2. **Disjunción por `files_scope` y por el grafo (G2).** `scripts/lib/front-plan.py` calcula, a
349
+ partir de los globs declarados en `files_scope` de cada épica candidata, qué subconjunto es
350
+ mutuamente disjunto (`selected`, va al front) y cuál se solapa (`serialized`, espera y se
351
+ construye después, secuencial). Sin `files_scope` declarado, una épica **no es candidata** al
352
+ front: el script la serializa (fail-closed). Tampoco son paralelizables dos épicas unidas por
353
+ un camino en el DAG de `depends_on`, aunque sus ficheros sean disjuntos — la de abajo
354
+ construiría sobre lo que la de arriba todavía no ha mergeado.
344
355
  3. **Merge en orden + re-smoke (G3).** El merge de los worktrees sigue un `merge_order`
345
356
  determinista; tras **cada** merge se re-corre el `journey_smoke` completo sobre el árbol
346
357
  principal (no basta con el smoke local del worktree). Un solape no capturado por la disjunción
package/README.md CHANGED
@@ -121,14 +121,21 @@ Cuando hay **≥ 2 épicas de negocio (`layer: business`)** listas (DoR pasado)
121
121
  archivos**, el comando **`/build:front`** (skill `managing-parallel-front`) las construye en
122
122
  paralelo, cada una en su propio `git worktree`/rama/PR — el inner loop no cambia: cada worktree
123
123
  mantiene su `active_slice` singular y su propio `build-state.json`. Tres compuertas gobiernan el
124
- front:
124
+ front (más una compuerta cero, de identidad, cuando se corre contra el hub):
125
125
 
126
+ 0. **Un agente por worktree, un token por agente** (modo `runtime`). Cada árbol se da de alta con
127
+ `slice-ops.sh register` y un token que el ADMIN emitió **para él**: el hub deriva el `agent_key`
128
+ del token, así que dos worktrees con el mismo token son un solo agente. Un árbol sin dar de alta
129
+ hereda las credenciales del clon principal y lo suplantaría — por eso toda escritura desde él
130
+ aborta con `rc 9`.
126
131
  1. **Foundational-first.** Ninguna épica `layer: foundational` entra jamás al front; si una aparece
127
132
  mientras el front está activo, el front pasa a `status: "draining"` (termina lo que ya empezó, no
128
133
  admite épicas nuevas) hasta vaciarse.
129
- 2. **Disjunción por `files_scope`.** `scripts/lib/front-plan.py` calcula, por conjuntos de globs
130
- declarados en cada épica, qué candidatas son mutuamente disjuntas (`selected`) y cuáles deben
131
- esperar (`serialized`) por solaparse.
134
+ 2. **Disjunción por `files_scope` y por el grafo.** `scripts/lib/front-plan.py` calcula, por
135
+ conjuntos de globs declarados en cada épica, qué candidatas son mutuamente disjuntas
136
+ (`selected`) y cuáles deben esperar (`serialized`) por solaparse. Sin `files_scope` declarado una
137
+ épica **no es candidata** (se serializa), y dos épicas unidas por un camino en el DAG de
138
+ `depends_on` tampoco van juntas aunque sus ficheros sean disjuntos.
132
139
  3. **Merge en orden + re-smoke.** El merge de los worktrees sigue un `merge_order` determinista; tras
133
140
  cada merge se re-corre el `journey_smoke` completo en el árbol principal. Un conflicto no
134
141
  detectado por la disjunción declarada lo caza el re-smoke: la épica perdedora se serializa
@@ -144,7 +151,7 @@ trycore-spec-build-harness/
144
151
  ├── agents/build/ ← 14 agentes revisores (segunda opinión, contexto limpio)
145
152
  ├── commands/
146
153
  │ ├── opsx/ ← 10 comandos /opsx:* (ciclo OpenSpec)
147
- │ └── build/ ← 13 comandos /build:* (onboard, reflect, architect, prototype, slice, release, work, resume, front, claim, status, escalate, epic)
154
+ │ └── build/ ← 14 comandos /build:* (onboard, reflect, architect, prototype, slice, release, work, resume, front, claim, status, escalate, epic, graph-sync)
148
155
  ├── 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)
149
156
  ├── 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)
150
157
  ├── state/ ← máquina de estado legacy: build-state.json + schema + README
@@ -184,7 +191,36 @@ El core no menciona ningún dominio de cliente. Toda parametrización entra por
184
191
 
185
192
  ## Roadmap
186
193
 
187
- - ✅ **v0.14.0 (actual) — contexto vivo, épicas propuestas y versión de grafo** (#61, #62, #63)
194
+ - ✅ **v0.16.0 (actual) — identidad por árbol de trabajo** (#74) construir dos épicas en paralelo
195
+ en la misma máquina, una por `git worktree`, ya es una operación segura. El reparto nunca fue el
196
+ problema del hub —ya entrega dos épicas a dos agentes sin objeción—: el bloqueo estaba en el
197
+ cliente. El estado del arnés está en `.gitignore` y no viaja a un worktree, así que el árbol nuevo
198
+ **heredaba en silencio** las credenciales del clon principal y lo suplantaba: reclamaba con su
199
+ `agent_key`, reportaba los sha del árbol equivocado (cegando la detección de drift justo en el
200
+ escenario paralelo) y compartía su heartbeat. Ahora toda **escritura** desde un árbol sin dar de
201
+ alta aborta con **`rc 9`** antes de abrir un socket (las lecturas siguen, y `status` avisa con
202
+ `identidad: ⚠ HEREDADA`), y el alta es explícita: **`slice-ops.sh register`**, con el token por
203
+ **stdin** —nunca por argumento—, siembra local antes de tocar la red, verificación de que el
204
+ `agent_key` **difiera** del principal y rollback total ante cualquier fallo. Dato que corrige la
205
+ documentación anterior: `POST /agents/register` **no crea identidad** —el `agent_key` es el
206
+ `agent_id` grabado en el token por el ADMIN—, así que es **un token por agente**, no uno por
207
+ proyecto. Además, `front-plan.py` cierra tres fallos silenciosos: `layer` normalizado (el bundle
208
+ del grafo emite `FOUNDATIONAL` y la exclusión foundational-first podía no disparar),
209
+ **fail-closed** sin `files_scope` declarado, y serialización de los pares con camino en el DAG de
210
+ `depends_on`.
211
+ - ✅ **v0.15.0 — el arnés propone el re-sync de su grafo** (EP-OR-17) — el grafo del hub se sembraba
212
+ una vez, con el import de admin de `/build:onboard`, y ahí se quedaba: las épicas que discovery
213
+ escribía después no tenían por dónde entrar y **todos** los eventos de un slice cuya épica falta
214
+ rebotan. Comando **`/build:graph-sync`** (+ `--check`) y subcomandos `graph-sync`/
215
+ `graph-sync-status`: el arnés construye el bundle desde los docs, el hub calcula el delta y un
216
+ **humano aprueba** en la consola — nada se aplica sin esa aprobación; tras ella, las historias
217
+ nuevas entran solas al reparto del claim. `client_event_id` determinista para que el reintento
218
+ deduplique en vez de dejar dos propuestas del mismo grafo esperando. En el camino, dos defectos
219
+ vivos desde antes: el `409` se leía en la raíz y con un nombre que no existe en el backend (el
220
+ claim no reintentaba ante grafo rancio ni ante drift), y el **espejo del modo `dual` era un 422 el
221
+ 100 % de las veces** (mandaba un cuerpo que el contrato no tiene). Total: **14 comandos
222
+ `/build:*`**.
223
+ - ✅ **v0.14.0 — contexto vivo, épicas propuestas y versión de grafo** (#61, #62, #63) —
188
224
  tres capacidades del cliente del runtime que componen entre sí. **(1)** El daemon de heartbeat
189
225
  refresca `GET /agent/context` con **cadencia propia** (`runtime.context_refresh_s`, default 45 s,
190
226
  `0` la desactiva): antes la caché se hidrataba al arrancar la sesión y en cada `claim` —y el
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.15.0
1
+ 0.16.0
@@ -23,6 +23,9 @@ Eres el único que reporta gates y transiciona fases; los reviewers solo emiten
23
23
  ## Coexistencia con el front paralelo (outer-loop)
24
24
  Si hay un front paralelo abierto, el paralelismo lo gobierna la skill
25
25
  `managing-parallel-front`; cada worktree corre su propio inner loop con `active_slice` singular.
26
+ En modo `runtime`, cada worktree es además un **agente propio** con su token del ADMIN (alta con
27
+ `slice-ops.sh register`): si un árbol no está dado de alta, sus escrituras salen con `rc 9` porque
28
+ estaría operando con la identidad del clon principal — eso se arregla registrando, no reintentando.
26
29
  **Regla de drenado:** si se necesita abrir una épica `layer=foundational`, primero pon
27
30
  `parallel_front.status="draining"` (termina las en curso, no admite nuevas) y espera a cerrarlo.
28
31
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "BUILD: Front"
3
- description: Prepara y coordina un front paralelo de épicas NO fundacionales y disjuntas en archivos, cada una en su worktree/rama/PR con su propio agente registrado. Delega en la skill managing-parallel-front (selección disjunta local vía scripts/lib/front-plan.py, worktrees, merge en orden con re-smoke reportado por cada worktree). Planificar, abrir, drenar y cerrar el front son actos humanos en la consola.
3
+ description: Prepara y coordina un front paralelo de épicas NO fundacionales y disjuntas en archivos, cada una en su worktree/rama/PR con su propio agente registrado (un token por agente, alta con slice-ops.sh register). Delega en la skill managing-parallel-front (selección disjunta local vía scripts/lib/front-plan.py, worktrees, merge en orden con re-smoke reportado por cada worktree). Planificar, abrir, drenar y cerrar el front son actos humanos en la consola.
4
4
  category: Workflow
5
5
  tags: [build-harness, outer-loop, front-paralelo, trycore]
6
6
  ---
@@ -10,11 +10,15 @@ tags: [build-harness, outer-loop, front-paralelo, trycore]
10
10
  Delega en la skill **managing-parallel-front**. Resumen:
11
11
  1. Verifica precondiciones (scaffold confirmado; sin épica foundational abierta) con
12
12
  `bash .claude/hooks/build/slice-ops.sh status`.
13
- 2. Reúne candidatas no fundacionales listas (DoR pasado) con `layer` y `files_scope`.
13
+ 2. Reúne candidatas no fundacionales listas (DoR pasado) con `layer`, `files_scope` y
14
+ `depends_on`. Sin `files_scope` declarado la épica **no es candidata** (se serializa).
14
15
  3. Propón el conjunto disjunto y el `merge_order` (`scripts/lib/front-plan.py`, local) y
15
16
  **preséntaselo a una persona**: abrir el front es acto de gobierno, en la consola del hub.
16
- 4. Un worktree por épica aprobada, cada uno registrado como **agente propio**
17
- (`trycore-build init` con el mismo token de proyecto); construye con `/build:slice`.
17
+ 4. Un worktree por épica aprobada, cada uno dado de alta como **agente propio** con **su
18
+ propio token** emitido por el ADMIN: `trycore-build init` (sin `--runtime-token`) y luego
19
+ `bash .claude/hooks/build/slice-ops.sh register` (pide el token por stdin). Sin ese alta,
20
+ toda escritura desde el worktree sale con **rc 9**: estaría suplantando al clon principal.
21
+ Después, construye con `/build:slice`.
18
22
  5. Tras cada merge, re-smoke y reporte desde el worktree:
19
23
  `bash .claude/hooks/build/release-ops.sh front-integration <front_id> --merge-status … --resmoke …`.
20
24
 
package/docs/commands.md CHANGED
@@ -121,7 +121,7 @@ El canal CLI namespacea por subcarpeta: `.claude/commands/opsx/` → `/opsx:*` y
121
121
 
122
122
  | Slash command | Propósito |
123
123
  |---|---|
124
- | `/build:front` | Abre y coordina un **front paralelo** de épicas NO fundacionales y disjuntas en archivos, cada una en su propio worktree/rama/PR. Delega en la skill `managing-parallel-front`: verifica precondiciones (scaffold confirmado, sin épica foundational abierta), selecciona el conjunto disjunto (`scripts/lib/front-plan.py`) y mergea en orden con re-smoke. Úsalo solo con ≥2 épicas no fundacionales disjuntas listas; para una sola épica, usa `/build:slice`. |
124
+ | `/build:front` | Abre y coordina un **front paralelo** de épicas NO fundacionales y disjuntas en archivos, cada una en su propio worktree/rama/PR. Delega en la skill `managing-parallel-front`: verifica precondiciones (scaffold confirmado, sin épica foundational abierta), selecciona el conjunto disjunto (`scripts/lib/front-plan.py`, que serializa también las candidatas sin `files_scope` declarado y los pares con camino en el DAG de `depends_on`) y mergea en orden con re-smoke. En modo `runtime`, **cada worktree se da de alta como agente propio** con su token del ADMIN (`slice-ops.sh register`): sin ese alta, toda escritura desde el árbol sale con `rc 9` porque estaría firmando con la identidad del clon principal. Úsalo solo con ≥2 épicas no fundacionales disjuntas listas; para una sola épica, usa `/build:slice`. |
125
125
 
126
126
  ### `/build:prototype` — prototipo HTML de referencia (fuente de diseño)
127
127
 
@@ -134,6 +134,14 @@ El canal CLI namespacea por subcarpeta: `.claude/commands/opsx/` → `/opsx:*` y
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.
136
136
 
137
+ > **Un árbol de trabajo, un agente.** Estos comandos operan con la identidad de
138
+ > `.claude/state/runtime.credentials`. Ese fichero está en `.gitignore` y **no viaja** a un
139
+ > `git worktree`, así que un árbol nuevo hereda la del clon principal y lo suplantaría: por eso
140
+ > toda **escritura** desde un árbol sin dar de alta aborta con **`rc 9`**. El alta no es un slash
141
+ > command —pide el token por stdin y necesita un terminal humano—: se corre una vez por worktree
142
+ > con `bash .claude/hooks/build/slice-ops.sh register`. Ver
143
+ > [guía runtime → «Varios agentes en la misma máquina»](runtime/guia-modo-dual-y-migracion.md).
144
+
137
145
  | Slash command | Propósito |
138
146
  |---|---|
139
147
  | `/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). |
@@ -56,7 +56,7 @@ npm i -g @fission-ai/openspec @trycore/spec-build-harness
56
56
 
57
57
  ## 2 · `trycore-build init` (terminal)
58
58
 
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.
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:*`** + **14 comandos `/build:*`** (`onboard`, `reflect`, `architect`, `prototype`, `slice`, `release`, `work`, `resume`, `front` + `claim`, `status`, `escalate`, `epic`, `graph-sync`, 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
60
 
61
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.
62
62
 
package/docs/hooks.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Hooks del arnés de construcción
2
2
 
3
- Este documento describe los **19 hooks** que `@trycore/spec-build-harness` instala en el proyecto del consumidor. Todos viven en `hooks/build/` (un script bash o python por hook, más los helpers compartidos `lib/state-io.sh`, `lib/config.sh`, `lib/runtime-client.sh`, `lib/runtime-ops.sh`, `lib/agent-context.sh`, `lib/projection.sh`) y se cablean a los eventos de Claude Code mediante una **cadena de comando única**, idéntica en ambos canales de instalación.
3
+ Este documento describe los **19 hooks** que `@trycore/spec-build-harness` instala en el proyecto del consumidor. Todos viven en `hooks/build/` (un script bash o python por hook, más los helpers compartidos `lib/state-io.sh`, `lib/config.sh`, `lib/runtime-client.sh`, `lib/runtime-ops.sh`, `lib/agent-context.sh`, `lib/projection.sh`, `lib/harness-meta.sh`) y se cablean a los eventos de Claude Code mediante una **cadena de comando única**, idéntica en ambos canales de instalación.
4
4
 
5
5
  Los hooks son el sistema nervioso del arnés: vigilan GitFlow, el stack declarado, el scaffold y la fuente de diseño (bloqueantes), inyectan y re-anclan el estado del build al iniciar la sesión, delegan estilo/typecheck a herramientas, vigilan la presión de contexto y escriben handoff automático, y recuerdan validar trazabilidad, gates abiertos, reflexionar al cerrar un slice y correr el Release Gate cuando se acumulan épicas sin auditar. No reemplazan a los agentes ni a las skills; los **complementan** liberando capacidad de razonamiento del modelo y poniendo barandillas mecánicas donde un olvido cuesta caro.
6
6
 
@@ -41,7 +41,9 @@ Los hooks son el sistema nervioso del arnés: vigilan GitFlow, el stack declarad
41
41
  >
42
42
  > `lib/state-io.sh` (`legacy`), `lib/config.sh` (`runtime.mode`, umbrales), `lib/runtime-client.sh`
43
43
  > (cliente HTTP + cola offline), `lib/runtime-ops.sh` (helpers de `slice-ops.sh`/`release-ops.sh`),
44
- > `lib/agent-context.sh` (normaliza `GET /agent/context`) y `lib/projection.sh` (lee esa caché,
44
+ > `lib/agent-context.sh` (normaliza `GET /agent/context`), `lib/harness-meta.sh` (versión del arnés
45
+ > y catálogo de asset types, compartidos por el registro de sesión y el alta de un worktree) y
46
+ > `lib/projection.sh` (lee esa caché,
45
47
  > sin red) **no son hooks**: son los helpers compartidos que los scripts de arriba `source`an.
46
48
 
47
49
  ---
@@ -120,12 +120,13 @@ arman peticiones a mano. En `dual`, cada transición también se **espeja** al s
120
120
  identidad del fichero (que sigue mandando). Si el espejo falla (red caída, servidor rechaza), el
121
121
  trabajo local **nunca se bloquea** — se avisa y queda como discrepancia para el comparador.
122
122
 
123
- `slice-ops.sh` tiene **19 subcomandos** (los conducen las skills; los listamos para que puedas
124
- leer un log o depurar, no para que los teclees):
123
+ `slice-ops.sh` tiene **20 subcomandos** (los conducen las skills; los listamos para que puedas
124
+ leer un log o depurar, no para que los teclees — `register` es la excepción: ese lo tecleas tú):
125
125
 
126
126
  | Subcomando | Para qué |
127
127
  |---|---|
128
128
  | `mode` | imprime `legacy`\|`dual`\|`runtime` |
129
+ | `register` | da de alta **este árbol de trabajo** como agente propio, con su token (lo pide por stdin). Solo hace falta en un `git worktree` — ver «Varios agentes en la misma máquina» |
129
130
  | `claim` | reclama trabajo (el hub reparte por orden de cola; sin épica dirigida) |
130
131
  | `next-step` | deriva la siguiente acción desde la caché de proyección |
131
132
  | `phase` | reporta la fase del pipeline (emite la cadena que falte; idempotente) |
@@ -142,7 +143,47 @@ leer un log o depurar, no para que los teclees):
142
143
  Del lado release, `release-ops.sh` añade `verdict` y `front-integration`.
143
144
 
144
145
  **Códigos de salida** (sobre estos ramifica la prosa de las skills): `0` ok · `2` uso · `3` legacy ·
145
- `4` sin slice · `5` offline · `6` rechazado · `7` sin trabajo · `8` tipo no espejable.
146
+ `4` sin slice · `5` offline · `6` rechazado · `7` sin trabajo · `8` tipo no espejable ·
147
+ `9` identidad heredada (este árbol no está dado de alta — ver abajo).
148
+
149
+ ### Varios agentes en la misma máquina (0.16.0)
150
+
151
+ Si abres un `git worktree` por épica para construir en paralelo (`/build:front`), **cada árbol
152
+ necesita su propio token de agente**, emitido por el ADMIN para él. No es burocracia:
153
+
154
+ - El hub **no crea identidad** al registrar. El `agent_key` es el `agent_id` que va grabado en el
155
+ token, así que dos árboles con el mismo token son **un solo agente** — y el segundo `claim` te
156
+ devuelve el slice que ya tiene reclamado el primero.
157
+ - El estado del arnés está en `.gitignore` y **no viaja** al worktree. Sin credenciales propias, el
158
+ árbol nuevo hereda las del clon principal y **suplanta** a ese agente: reclamaría con su
159
+ `agent_key`, reportaría los hashes del árbol equivocado y compartiría su heartbeat.
160
+
161
+ Por eso, en un árbol sin dar de alta, **toda escritura** (`claim`, `phase`, `gate`, `submit`,
162
+ `archive`, `wiring`, `progress`, `checkpoint`, `fact`, `propose-*`, `graph-sync`, `escalate` y
163
+ `release-ops.sh front-integration`) aborta con `rc 9` **antes de tocar la red**. Las lecturas siguen
164
+ funcionando —son el diagnóstico de ese árbol— y `status` lo dice en su primera línea:
165
+
166
+ ```
167
+ identidad: ⚠ HEREDADA del clon principal (/ruta/al/clon) — solo lectura
168
+ ```
169
+
170
+ El alta es un comando, en el terminal, una vez por worktree:
171
+
172
+ ```bash
173
+ cd .wt/ep-003
174
+ trycore-build init # tiende .claude/ (sin --runtime-token)
175
+ bash .claude/hooks/build/slice-ops.sh register # pide el token por stdin
176
+ ```
177
+
178
+ `register` **no acepta el token por argumento** (`--token` sale con `rc 2`): en argv quedaría en el
179
+ historial del shell y en `ps`. Por lo mismo lo corres tú y no un hook — las tool calls no tienen
180
+ TTY. Antes de tocar la red siembra el fichero local (si no, el registro habría escrito sobre las
181
+ credenciales del clon principal), y después verifica contra el hub que el `agent_key` obtenido
182
+ **difiere** del suyo; si coincide, lo rechaza y te dice que pidas al ADMIN un token con `agent_id`
183
+ distinto. Ante cualquier fallo revierte: el árbol queda con identidad propia y verificada, o
184
+ exactamente como estaba. El fichero del clon principal no se toca nunca.
185
+
186
+ Para rotar el token de un árbol ya registrado: `register --force`.
146
187
 
147
188
  ### Qué espeja el modo `dual` — y qué no (0.15.0)
148
189
 
@@ -9,6 +9,11 @@
9
9
  - **Token de proyecto:** emitido por un ADMIN en el hub, entregado al desarrollador, configurado una vez con `trycore-build init --runtime-url --runtime-token` (se guarda en `.claude/state/runtime.credentials`, `0600`, sembrado en `.gitignore` por `init`). Todas las llamadas: `Authorization: Bearer <token>`.
10
10
  - **Registro:** `POST /agents/register {harness_version, asset_types}` → el servidor responde `{agent_key, project_id, harness_version, poll_interval_s, lease_ttl_s, manifest_hash, catalog{…}}`; el cliente (`runtime_register`) persiste hoy solo `agent_key`, `project_id`, `poll_interval_s`, `lease_ttl_s` y `manifest_hash` — `catalog` (tipos de asset) todavía no se persiste (el cliente ya envía `asset_types` desde `.claude/asset-types.json`, pero no consume el `catalog` de vuelta), `harness_version` no se re-persiste (ya se conoce localmente). Los intervalos los dicta el servidor (el cliente no los hardcodea). `409 incompatible_version` ⇒ mensaje claro con la versión mínima requerida (cuerpo `{error, min_version}`).
11
11
  - **Identidad local:** `agent_key`/`project_id` persisten en `.claude/state/runtime.credentials` (`0600`, protegido en `.gitignore` desde el primer `init`); un mismo clon re-registrado reactiva su agente (no crea otro): `runtime_register` hace upsert sobre el fichero existente, nunca lo recrea desde cero.
12
+ - **Un token = un agente.** `POST /agents/register` **no crea identidad**: el `agent_key` es el `agent_id` que el ADMIN grabó en el token al emitirlo. Dos árboles con el mismo token son **el mismo agente** para el hub, y el segundo `claim` devuelve el slice que ya tiene reclamado el primero. Para dos agentes hacen falta **dos tokens**.
13
+ - **Identidad por árbol de trabajo (`git worktree`, issue #74).** El estado del arnés está en `.gitignore` y **no viaja** a un `git worktree add`. `config_root()` cae entonces al clon principal (`--git-common-dir`) para que el árbol nuevo no quede huérfano de contexto — pero ese fallback vale para **leer**, no para **escribir**: sin credenciales propias, el worktree firmaría cada acto con el `agent_key` del clon principal, reportaría los hashes del árbol equivocado y compartiría su heartbeat. Por eso:
14
+ - Toda operación de **escritura** (`slice-ops.sh claim|phase|gate|submit|archive|wiring|progress|checkpoint|fact|propose-asset|propose-epic|epic-writeback|graph-sync|escalate` y `release-ops.sh front-integration`) aborta con **rc 9** antes de tocar la red si la identidad es heredada.
15
+ - Las **lecturas** (`mode`, `next-step`, `status`, `epic-status`, `graph-sync-status`) conservan el fallback: son el diagnóstico del árbol que aún no se dio de alta. `status` lo encabeza con `identidad: ⚠ HEREDADA del clon principal … — solo lectura`.
16
+ - El alta es explícita: **`slice-ops.sh register`** (§10). Con ella, `ops_context_hashes`, el heartbeat, la outbox y los ledgers pasan a ser por-worktree solos — todos cuelgan de `config_root()`.
12
17
 
13
18
  ## 2. Mapeo hook → endpoint (la telemetría es del harness, no del modelo)
14
19
 
@@ -215,6 +220,7 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
215
220
  | Comando | Superficie |
216
221
  |---|---|
217
222
  | `slice-ops.sh mode` | ninguna (lee `build-config.json`) |
223
+ | `slice-ops.sh register [--runtime-url U] [--force]` | `POST /agents/register` con un token **propio de este árbol**. Da de alta un worktree como agente distinto (§1). El token entra por **stdin** (prompt si hay TTY), nunca por argumento —`--token` sale con rc 2— porque en argv queda en el historial del shell y en `ps`; por lo mismo es un comando de terminal humano y **no** se invoca desde dentro de un claim (los hooks no tienen TTY y un `read` colgaría la llamada). Orden obligado: siembra el fichero local **primero** (si no, el upsert caería sobre las credenciales del clon principal y le pisaría la identidad), registra, y **verifica que el `agent_key` obtenido difiera** del del principal. Cualquier fallo revierte: el árbol queda con identidad propia y verificada, o exactamente como estaba; el fichero del clon principal no se toca nunca. `0` alta (o ya registrado) · `2` uso · `5` sin red · `6` mismo agente que el principal / token rechazado |
218
224
  | `slice-ops.sh claim` | `POST /tasks/next` (§3); `--epic` falla explícito, rc 2 (no hay claim dirigido, §3.6) |
219
225
  | `slice-ops.sh next-step` | ninguna: lo **deriva el cliente** desde la caché de proyección |
220
226
  | `slice-ops.sh phase <to>` | evento(s) `phase_advanced {to}` en cadena (issue #52): el hub lleva la fase con orden ESTRICTO (`dor→change→red→green→refactor→smoke→api→data→dod→pr`, `domain.py` `PHASES`) y solo acepta la fase siguiente exacta. El cliente emite la cadena que falte desde la fase conocida (proyección ∪ `phase_advanced` pendientes en la cola) hasta `<to>`; idempotente (nada si ya está). `archived` no se reporta aquí (lo produce `archive`). Siempre por cola en `runtime` (tipo protegido de la cota: evictar un eslabón haría ilegal todo lo posterior); espejo en `dual` (`phase_advanced` ∈ `TIPOS_DE_ESPEJO`) |
@@ -235,6 +241,10 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
235
241
  | `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
236
242
  | `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration`; **sin fallback offline** (rc 5: `front_integration_reported` no existe en el catálogo v2, issue #44) |
237
243
 
244
+ **Códigos de salida.** `0` ok · `2` uso · `3` legacy · `4` sin slice · `5` offline · `6` rechazado ·
245
+ `7` sin trabajo · `8` fuera del catálogo de espejo · **`9` identidad heredada** (#74: este árbol
246
+ opera con las credenciales del clon principal; da de alta el worktree con `register`).
247
+
238
248
  **Forma del borrador de `propose-epic --file`:**
239
249
 
240
250
  ```json
@@ -16,23 +16,71 @@
16
16
  # Por eso, cuando el árbol actual no tiene estado, se cae al clon principal
17
17
  # (--git-common-dir), que es donde vive de verdad. Idempotente para un clon
18
18
  # normal: si el candidato ya tiene estado, se devuelve tal cual.
19
+ #
20
+ # [#74] Ese fallback vale para LEER, nunca para ESCRIBIR: quien vaya a transicionar algo
21
+ # tiene que preguntar antes por `config_is_inherited_root` (más abajo) y abortar, o estará
22
+ # operando con la identidad del clon principal sin saberlo.
19
23
  config_root() {
20
24
  local candidate
21
25
  candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
22
26
  [ -f "$candidate/.claude/state/runtime.credentials" ] && { echo "$candidate"; return; }
23
27
 
24
- local common main
25
- common="$(git -C "$candidate" rev-parse --git-common-dir 2>/dev/null)" || common=""
26
- if [ -n "$common" ]; then
27
- case "$common" in /*) ;; *) common="$candidate/$common" ;; esac
28
- main="$(cd "$common/.." 2>/dev/null && pwd)" || main=""
29
- if [ -n "$main" ] && [ -f "$main/.claude/state/runtime.credentials" ]; then
30
- echo "$main"; return
31
- fi
28
+ local main
29
+ main="$(config_main_clone_root)"
30
+ if [ -n "$main" ] && [ -f "$main/.claude/state/runtime.credentials" ]; then
31
+ echo "$main"; return
32
32
  fi
33
33
  echo "$candidate"
34
34
  }
35
35
 
36
+ # ── Identidad por árbol de trabajo [#74] ─────────────────────────────────────────────
37
+ # El fallback de config_root es deliberado para LEER (un worktree sin estado propio sigue
38
+ # viendo el contexto del proyecto), pero es veneno para ESCRIBIR: un worktree sin
39
+ # credenciales propias impersona en silencio al agente del clon principal — reclama con su
40
+ # `agent_key`, reporta los hashes de SU árbol (no del propio), comparte su heartbeat y, al
41
+ # registrarse, le pisaría el fichero de credenciales. Estos tres helpers son la señal que
42
+ # permite a las operaciones de escritura fallar rápido en vez de suplantar.
43
+
44
+ # _config_phys <ruta> — forma FÍSICA de una ruta (resuelve symlinks). En macOS /var es un
45
+ # symlink a /private/var: comparar rutas sin normalizar mide cómo se escribió el path, no a
46
+ # qué apunta. Ante una ruta inaccesible devuelve la entrada tal cual — nunca lanza.
47
+ _config_phys() {
48
+ (cd "$1" 2>/dev/null && pwd -P) || printf '%s\n' "$1"
49
+ }
50
+
51
+ # config_candidate_root — la raíz que corresponde a ESTE árbol de trabajo, SIN fallback:
52
+ # `CLAUDE_PROJECT_DIR` o el toplevel del árbol actual. Es el otro extremo de la comparación
53
+ # de identidad (config_root es «de quién es el estado que voy a usar»; esto es «dónde estoy»).
54
+ config_candidate_root() {
55
+ local candidate
56
+ candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
57
+ _config_phys "$candidate"
58
+ }
59
+
60
+ # config_main_clone_root — raíz del clon principal según `--git-common-dir` ("" si no hay
61
+ # git). En un clon normal coincide con el propio árbol; en un worktree apunta al clon que lo
62
+ # creó. Es la única forma de leer la identidad del principal sin depender de config_root
63
+ # (que es justo lo que puede estar heredado).
64
+ config_main_clone_root() {
65
+ local candidate common main
66
+ candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
67
+ common="$(git -C "$candidate" rev-parse --git-common-dir 2>/dev/null)" || common=""
68
+ [ -n "$common" ] || { echo ""; return; }
69
+ case "$common" in /*) ;; *) common="$candidate/$common" ;; esac
70
+ main="$(cd "$common/.." 2>/dev/null && pwd -P)" || main=""
71
+ echo "$main"
72
+ }
73
+
74
+ # config_is_inherited_root — rc 0 si el estado que se va a usar NO es el de este árbol, es
75
+ # decir: config_root cayó al clon principal. rc 1 en un clon normal, en un worktree ya
76
+ # registrado y en un proyecto sin arnés (donde no hay identidad que heredar).
77
+ config_is_inherited_root() {
78
+ local here there
79
+ here="$(config_candidate_root)"
80
+ there="$(_config_phys "$(config_root)")"
81
+ [ -n "$here" ] && [ -n "$there" ] && [ "$here" != "$there" ]
82
+ }
83
+
36
84
  # config_get <clave.punteada> <default>
37
85
  config_get() {
38
86
  local key="$1" def="$2" file
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # harness-meta.sh — metadatos del PAQUETE instalado (versión del arnés y catálogo de asset
3
+ # types). Extraído de session-start.sh [#74] para que `slice-ops.sh register` mande
4
+ # exactamente el mismo `harness_version` que el registro de arranque de sesión: dos lecturas
5
+ # distintas de la misma versión acabarían con un worktree rechazado por
6
+ # `incompatible_version` y el clon principal aceptado, o al revés.
7
+ # Fail-open, igual que config.sh: nunca lanza; sin fichero devuelve un valor neutro.
8
+
9
+ source "$(dirname "${BASH_SOURCE[0]}")/config.sh"
10
+
11
+ # harness_pkg_root — raíz del PAQUETE (no del proyecto). Los hooks se instalan como symlinks
12
+ # por fichero (linkChildren), así que hay que resolver la cadena de enlaces a mano:
13
+ # `readlink -f` no es portable al BSD readlink de macOS. Este fichero vive en
14
+ # `<pkg>/hooks/build/lib/`, de ahí los tres niveles.
15
+ harness_pkg_root() {
16
+ local src="${BASH_SOURCE[0]}" target i=0
17
+ while [ -L "$src" ] && [ "$i" -lt 10 ]; do
18
+ target="$(readlink "$src" 2>/dev/null)" || break
19
+ case "$target" in
20
+ /*) src="$target" ;;
21
+ *) src="$(dirname "$src")/$target" ;;
22
+ esac
23
+ i=$((i + 1))
24
+ done
25
+ (cd "$(dirname "$src")/../../.." >/dev/null 2>&1 && pwd -P) 2>/dev/null
26
+ }
27
+
28
+ # harness_version — versión instalada en el consumidor; si no, la del paquete.
29
+ # [#74] El marcador `.claude/.build-harness-version` está en .gitignore en modo symlink: NO
30
+ # viaja a un `git worktree add`. Por eso se mira también el clon principal antes de caer al
31
+ # paquete — un worktree recién dado de alta no tiene por qué mentir sobre su versión.
32
+ harness_version() {
33
+ local f pkg root
34
+ for root in "$(config_root)" "$(config_candidate_root)" "$(config_main_clone_root)"; do
35
+ [ -n "$root" ] || continue
36
+ f="$root/.claude/.build-harness-version"
37
+ [ -f "$f" ] && { tr -d ' \n' < "$f"; return; }
38
+ done
39
+ pkg="$(harness_pkg_root)"
40
+ if [ -n "$pkg" ] && [ -f "$pkg/VERSION" ]; then
41
+ tr -d ' \n' < "$pkg/VERSION"
42
+ return
43
+ fi
44
+ echo "0.0.0"
45
+ }
46
+
47
+ # harness_asset_types — el array `types` de asset-types.json del paquete (protocolo §6).
48
+ # Cadena de resolución: plugin > copia en .claude/ (la siembra es del sub-slice D) >
49
+ # paquete resuelto por symlink. Sin fichero: "[]" — el registro sigue funcionando.
50
+ harness_asset_types() {
51
+ local pkg candidates f
52
+ pkg="$(harness_pkg_root)"
53
+ candidates="${CLAUDE_PLUGIN_ROOT:-}/asset-types.json
54
+ $(config_root)/.claude/asset-types.json
55
+ ${pkg:-}/asset-types.json"
56
+ while IFS= read -r f; do
57
+ case "$f" in /asset-types.json|asset-types.json) continue ;; esac
58
+ [ -f "$f" ] || continue
59
+ python3 - "$f" <<'PY' 2>/dev/null && return
60
+ import json,sys
61
+ try:
62
+ d=json.load(open(sys.argv[1]))
63
+ t=d.get("types")
64
+ print(json.dumps(t if isinstance(t,list) else [],ensure_ascii=False))
65
+ except Exception:
66
+ raise SystemExit(1)
67
+ PY
68
+ done <<EOS
69
+ $candidates
70
+ EOS
71
+ echo "[]"
72
+ }
@@ -46,11 +46,39 @@ except Exception:
46
46
  PY
47
47
  }
48
48
 
49
+ # runtime_credentials_field_at <fichero> <clave> — lee un campo de un fichero de credenciales
50
+ # CONCRETO, sin pasar por config_root(). [#74] Es la única forma de mirar la identidad del clon
51
+ # principal desde un worktree: `runtime_field` resuelve por config_root(), que es justo lo que
52
+ # puede estar heredado — preguntarle «¿cuál es el agent_key del principal?» devolvía el propio.
53
+ runtime_credentials_field_at() {
54
+ local file="$1" key="$2"
55
+ [ -f "$file" ] || { echo ""; return; }
56
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
57
+ python3 - "$file" "$key" <<'PY' 2>/dev/null
58
+ import json,sys
59
+ file,key=sys.argv[1],sys.argv[2]
60
+ try:
61
+ d=json.load(open(file))
62
+ v=d.get(key)
63
+ print(v if v is not None else "")
64
+ except Exception:
65
+ print("")
66
+ PY
67
+ }
68
+
49
69
  # runtime_credentials_merge <json> — hace upsert de las claves del fragmento sobre el
50
70
  # fichero existente (o uno nuevo). Escritura atómica + chmod 0600. Nunca imprime el token.
51
71
  runtime_credentials_merge() {
52
- local fragment="$1" file dir
53
- file="$(runtime_credentials_path)"
72
+ runtime_credentials_merge_at "$(runtime_credentials_path)" "$1"
73
+ }
74
+
75
+ # runtime_credentials_merge_at <fichero> <json> — el mismo upsert sobre una RUTA EXPLÍCITA.
76
+ # [#74] `register` tiene que sembrar el fichero del worktree ANTES de que exista, y por tanto
77
+ # antes de que config_root() pueda anclar en local: pasando por runtime_credentials_path()
78
+ # habría hecho merge del token nuevo sobre el fichero del clon principal, pisándole la
79
+ # identidad al agente que está construyendo ahí.
80
+ runtime_credentials_merge_at() {
81
+ local file="$1" fragment="$2" dir
54
82
  dir="$(dirname "$file")"
55
83
  mkdir -p "$dir"
56
84
  command -v python3 >/dev/null 2>&1 || return 1
@@ -78,6 +106,41 @@ except Exception:
78
106
  PY
79
107
  }
80
108
 
109
+ # runtime_credentials_seed_at <fichero> <runtime_url> — siembra credenciales en una RUTA
110
+ # EXPLÍCITA con el token tomado de `TRYCORE_REGISTER_TOKEN`. [#74] El token viaja por ENTORNO
111
+ # y no por argv a propósito: `runtime_credentials_merge_at` recibe el fragmento como argumento
112
+ # y en una máquina compartida un `ps` lo vería. Es el único camino por el que un secreto entra
113
+ # de nuevo al fichero, así que es el único que necesita esta precaución.
114
+ # Escribe un documento NUEVO, no un upsert: dar de alta un árbol es estrenar identidad, y
115
+ # conservar el `agent_key` anterior haría que una respuesta 200 sin `agent_key` pareciera un alta
116
+ # buena (`register --force` para rotar el token es justo el caso). Lo que el hub devuelva vuelve a
117
+ # entrar por `runtime_register`. Escritura atómica + chmod 0600.
118
+ runtime_credentials_seed_at() {
119
+ local file="$1" url="$2" dir
120
+ dir="$(dirname "$file")"
121
+ mkdir -p "$dir"
122
+ command -v python3 >/dev/null 2>&1 || return 1
123
+ python3 - "$file" "$url" <<'PY' 2>/dev/null || return 1
124
+ import json,os,sys,tempfile
125
+ path,url=sys.argv[1],sys.argv[2]
126
+ tok=os.environ.get("TRYCORE_REGISTER_TOKEN","")
127
+ if not tok:
128
+ raise SystemExit(1)
129
+ d={"runtime_url":url,"project_token":tok}
130
+ dirn=os.path.dirname(path) or "."
131
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".runtime-credentials.",suffix=".tmp")
132
+ try:
133
+ with os.fdopen(fd,"w") as out:
134
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
135
+ os.chmod(tmp,0o600)
136
+ os.replace(tmp,path)
137
+ except Exception:
138
+ try: os.unlink(tmp)
139
+ except OSError: pass
140
+ raise
141
+ PY
142
+ }
143
+
81
144
  runtime_url() {
82
145
  runtime_field runtime_url
83
146
  }
@@ -22,6 +22,31 @@ OPS_RC_OFFLINE=5
22
22
  OPS_RC_REJECTED=6
23
23
  OPS_RC_NO_WORK=7
24
24
  OPS_RC_NOT_MIRRORABLE=8
25
+ OPS_RC_NO_IDENTITY=9
26
+
27
+ # ── Guard de identidad heredada [#74] ────────────────────────────────────────────────
28
+ # Un worktree sin `.claude/state/runtime.credentials` propio hereda el del clon principal por
29
+ # el fallback de `config_root()`: lee bien, pero al ESCRIBIR suplanta — reclama con el
30
+ # `agent_key` ajeno, reporta al hub los hashes del árbol equivocado y comparte su heartbeat.
31
+ # El hub reparte dos épicas a dos agentes sin objeción; el bloqueo del multi-agente en una
32
+ # misma máquina siempre estuvo aquí, en el cliente. Por eso el guard es fail-FAST y no
33
+ # fail-open: la regla general de este cliente («ante la duda, no bloquees el trabajo local»)
34
+ # protege del runtime caído, no de mandar actos firmados con la identidad de otro.
35
+ #
36
+ # ops_guard_own_identity <subcomando> — rc 0 si este árbol tiene identidad propia;
37
+ # OPS_RC_NO_IDENTITY (9) con mensaje accionable si la heredó.
38
+ ops_guard_own_identity() {
39
+ local sub="${1:-esta operación}" main
40
+ config_is_inherited_root || return 0
41
+ main="$(config_main_clone_root)"
42
+ echo "⛔ este worktree no tiene identidad propia: «${sub}» operaría como el agente del clon" >&2
43
+ echo " principal (${main:-?}) — su agent_key, su lease, sus hashes de contexto." >&2
44
+ echo " Da de alta este árbol con un token de agente NUEVO emitido por el ADMIN:" >&2
45
+ echo " bash .claude/hooks/build/slice-ops.sh register" >&2
46
+ echo " (el token se pide por stdin, nunca por argumento). Las lecturas — \`status\`," >&2
47
+ echo " \`next-step\`, \`mode\` — siguen funcionando con el contexto heredado." >&2
48
+ return $OPS_RC_NO_IDENTITY
49
+ }
25
50
 
26
51
  # Tope de texto libre que el cliente manda al servidor (evidencias, notas): el protocolo §8
27
52
  # dice que el servidor rechaza payloads sobredimensionados; se trunca aquí, marcándolo.